mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9840e7efa | |||
| ec786aeaa8 | |||
| 76c97bfcc0 | |||
| 128bba380a | |||
| cd8bfce192 | |||
| 8f0edcd64f | |||
| 04e875c5c8 | |||
| a79da4f3a9 | |||
| 56809d7069 | |||
| 5dfbb739e5 | |||
| f87072f4e3 | |||
| 6a3175e973 | |||
| 4d1ca92a19 | |||
| b6fc583b0c | |||
| 0d6bd29752 | |||
| 78877942ec | |||
| 493b555646 | |||
| 75a8de83fc | |||
| a7beb890d0 | |||
| 60a6a47d56 | |||
| d313cd7dde | |||
| 77f4be1f3d | |||
| 0da1a2026a | |||
| 37edd1d4df | |||
| 165efa8a38 | |||
| 790f2cf815 | |||
| 704be5a418 | |||
| 9de8c8aadb | |||
| 0657d315fb | |||
| 88bdadc9c6 | |||
| 30c324dec7 | |||
| 40aa7397b6 | |||
| ab48aad213 | |||
| 4fa2c95c59 | |||
| 1b021bddab | |||
| 39de9586a0 | |||
| 393d72ac52 | |||
| c18ae0c382 | |||
| 381aae25b2 | |||
| b266912e61 | |||
| c6121d5113 |
@@ -0,0 +1,17 @@
|
||||
# Git history - not needed in build context
|
||||
.git
|
||||
|
||||
# Root node_modules - reinstalled inside container via npm ci
|
||||
node_modules
|
||||
|
||||
# Package-level node_modules - reinstalled inside container
|
||||
packages/*/node_modules
|
||||
|
||||
# Development and IDE files
|
||||
.github
|
||||
.vscode
|
||||
npm-debug.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.tmp
|
||||
@@ -53,11 +53,27 @@ Gemini CLI project.
|
||||
overriding values. Refer to `text-buffer.ts` for a canonical example.
|
||||
- **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in
|
||||
the code.
|
||||
- **State & Effects**: Ensure state initialization is explicit (e.g., use
|
||||
`undefined` rather than `true` as a default if the state is truly unknown).
|
||||
Carefully manage `useEffect` dependencies. Prefer a reducer whenever
|
||||
practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to
|
||||
correctly declare dependencies instead.
|
||||
- **State**: Ensure state initialization is explicit (e.g., use `undefined`
|
||||
rather than `true` as a default if the state is truly unknown). Prefer a
|
||||
reducer whenever practical. NEVER disable `react-hooks/exhaustive-deps`; fix
|
||||
the code to correctly declare dependencies instead. Evaluate all the React
|
||||
states in a component and ensure that the `useState` calls are necessary and
|
||||
not cases where values could be derived on render. Ensure there are no stale
|
||||
closures that are relying on a value from a previous render. React Components
|
||||
that modify Settings should effectively use the `useSettingsStore` pattern.
|
||||
Components that configure application Settings (e.g settings.json) are the
|
||||
only reasonable case for unsaved changes to drive UX; in these cases, the
|
||||
Settings store should only be written to on save. If the user experience does
|
||||
not utilize unsaved changes because there is no option to exit without saving
|
||||
or reverting the unsaved changes, then the component should directly read from
|
||||
and write to the Settings store without holding pending changes in component
|
||||
level UI state.
|
||||
- **Effect**: `useEffect` should not be used to synchronize React states, it
|
||||
should only be used for genuine side effects that occur outside of React.
|
||||
Contributors should be able to strongly justify the need for an effect.
|
||||
Consider whether the effect should instead be inside an event handler, or
|
||||
whether it is better off being computed on render. Carefully manage
|
||||
`useEffect` dependencies.
|
||||
- **Context & Props**: Avoid excessive property drilling. Leverage existing
|
||||
providers, extend them, or propose a new one if necessary. Only use providers
|
||||
for properties that are consistent across the entire application.
|
||||
|
||||
@@ -63,7 +63,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(gemini --version 2>/dev/null)
|
||||
gemini_version=$(gemini --version)
|
||||
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
|
||||
echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
|
||||
exit 1
|
||||
@@ -80,7 +80,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version 2>/dev/null)
|
||||
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
|
||||
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gemini Scheduled Lifecycle Manager Script
|
||||
* @param {object} param0
|
||||
* @param {import('@octokit/rest').Octokit} param0.github
|
||||
* @param {import('@actions/github/lib/context').Context} param0.context
|
||||
* @param {import('@actions/core')} param0.core
|
||||
*/
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const STALE_LABEL = 'stale';
|
||||
const NEED_INFO_LABEL = 'status/need-information';
|
||||
const PR_NUDGE_LABEL = 'status/pr-nudge-sent';
|
||||
const EXEMPT_LABELS = [
|
||||
'pinned',
|
||||
'security',
|
||||
'🔒 maintainer only',
|
||||
'help wanted',
|
||||
'🗓️ Public Roadmap',
|
||||
];
|
||||
|
||||
const STALE_DAYS = 60;
|
||||
const CLOSE_DAYS = 14;
|
||||
const NO_RESPONSE_DAYS = 14;
|
||||
const PR_NUDGE_DAYS = 7;
|
||||
const PR_CLOSE_DAYS = 14;
|
||||
|
||||
const now = new Date();
|
||||
const staleThreshold = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const closeThreshold = new Date(now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const noResponseThreshold = new Date(now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const prNudgeThreshold = new Date(now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const prCloseThreshold = new Date(now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Helper to process items with pagination and rate-limit awareness
|
||||
*/
|
||||
async function processItems(query, callback) {
|
||||
core.info(`Searching: ${query}`);
|
||||
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
order: 'asc',
|
||||
});
|
||||
|
||||
core.info(`Found ${items.length} items.`);
|
||||
for (const item of items) {
|
||||
try {
|
||||
await callback(item);
|
||||
} catch (err) {
|
||||
core.error(`Error processing #${item.number}: ${err.message}`);
|
||||
// Continue to next item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Handle No-Response (status/need-information)
|
||||
// Removal: Check issues updated in the last 48h that have the label and >1 comment
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()} comments:>1`,
|
||||
async (item) => {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
const lastComment = comments[0];
|
||||
if (
|
||||
lastComment &&
|
||||
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(lastComment.author_association) &&
|
||||
lastComment.user?.type !== 'Bot'
|
||||
) {
|
||||
core.info(`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
name: NEED_INFO_LABEL,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Closure: Check issues with the label that haven't been updated in 14 days
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 2. Handle Stale Mark (60 days inactivity, no stale label)
|
||||
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Marking #${item.number} as stale.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [STALE_LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Handle Stale Close (14 days with stale label)
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Closing stale item #${item.number}.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
|
||||
// Nudge: Older than 7d and no nudge label
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"${PR_NUDGE_LABEL}" created:<${prNudgeThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) || pr.user?.type === 'Bot') return;
|
||||
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [PR_NUDGE_LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Close: Has nudge label AND older than 14d AND untouched for 7d (grace period)
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr label:"${PR_NUDGE_LABEL}" -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()} updated:<${prNudgeThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) || pr.user?.type === 'Bot') return;
|
||||
|
||||
core.info(`Closing PR #${pr.number} per contribution policy (no 'help wanted' and grace period elapsed).`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -147,10 +147,7 @@ jobs:
|
||||
pull-requests: 'write'
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ${{ fromJSON(github.event_name == 'pull_request' && '["20.x"]' || '["20.x", "22.x", "24.x"]') }}
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
@@ -164,6 +161,9 @@ jobs:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies for testing'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
@@ -173,9 +173,6 @@ jobs:
|
||||
# Ubuntu 24.04+ requires this to allow bwrap to function in CI
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
|
||||
- name: 'Install dependencies for testing'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
@@ -242,10 +239,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ${{ fromJSON(github.event_name == 'pull_request' && '["20.x"]' || '["20.x", "22.x", "24.x"]') }}
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
@@ -259,12 +253,12 @@ jobs:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Install dependencies for testing'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
|
||||
|
||||
- name: 'Run Brain Phases'
|
||||
- name: 'Run Brain and Critique Loop'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
@@ -152,38 +152,76 @@ jobs:
|
||||
echo "</untrusted_context>" >> trigger_context.md
|
||||
fi
|
||||
|
||||
cat trigger_context.md "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md > combined_prompt.md
|
||||
MAX_ITERATIONS=4
|
||||
ITERATION=1
|
||||
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "Agent failed to respond. Generating fallback error message."
|
||||
echo "⚠️ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md"
|
||||
echo "" >> "issue-comment.md"
|
||||
echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md"
|
||||
fi
|
||||
|
||||
- name: 'Run Critique Phase'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
run: |
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes staged. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
while [ $ITERATION -le $MAX_ITERATIONS ]; do
|
||||
echo "========================================"
|
||||
echo "Starting Iteration $ITERATION"
|
||||
echo "========================================"
|
||||
|
||||
# --- BRAIN PHASE ---
|
||||
cat trigger_context.md > combined_prompt.md
|
||||
if [ -f "critique_feedback.md" ]; then
|
||||
cat critique_feedback.md >> combined_prompt.md
|
||||
fi
|
||||
cat "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md >> combined_prompt.md
|
||||
|
||||
echo "Running Brain Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "Agent failed to respond. Generating fallback error message."
|
||||
echo "⚠️ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md"
|
||||
echo "" >> "issue-comment.md"
|
||||
echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md"
|
||||
fi
|
||||
|
||||
# --- CRITIQUE PHASE ---
|
||||
if [ "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}" != "true" ]; then
|
||||
echo "PRs disabled, skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
fi
|
||||
|
||||
if git diff --staged --quiet && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "No changes staged and no comments generated. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
echo "Critique failed, rejected, or did not explicitly approve changes. Skipping PR creation."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Running Critique Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
echo "Critique Approved."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
else
|
||||
echo "Critique Rejected."
|
||||
if [ $ITERATION -lt $MAX_ITERATIONS ]; then
|
||||
echo "Preparing feedback for next iteration..."
|
||||
echo "<critique_feedback>" > critique_feedback.md
|
||||
echo "# Critique Feedback (Iteration $ITERATION)" >> critique_feedback.md
|
||||
echo "Your previous changes were rejected by the Critique agent. You MUST fix the following issues:" >> critique_feedback.md
|
||||
cat critique_output.log >> critique_feedback.md
|
||||
echo "</critique_feedback>" >> critique_feedback.md
|
||||
|
||||
# Discard rejected changes
|
||||
git reset
|
||||
git checkout .
|
||||
rm -f pr-description.md branch-name.txt pr-comment.md pr-number.txt issue-comment.md bot-changes.patch rejected-changes.patch
|
||||
else
|
||||
echo "Max iterations reached. Failing."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
# We still want to upload artifacts for debugging even if it failed.
|
||||
git diff --staged > rejected-changes.patch || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
ITERATION=$((ITERATION+1))
|
||||
done
|
||||
- name: 'Generate Patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
run: |
|
||||
@@ -203,6 +241,7 @@ jobs:
|
||||
tools/gemini-cli-bot/lessons-learned.md
|
||||
tools/gemini-cli-bot/history/*.csv
|
||||
bot-changes.patch
|
||||
rejected-changes.patch
|
||||
pr-description.md
|
||||
branch-name.txt
|
||||
pr-comment.md
|
||||
@@ -240,7 +279,7 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="main"
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: '🔄 Gemini Scheduled Lifecycle Manager'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *' # Once a day
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
manage-lifecycle:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Lifecycle Management'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const script = require('./.github/scripts/gemini-lifecycle-manager.cjs');
|
||||
await script({github, context, core});
|
||||
@@ -63,15 +63,15 @@ jobs:
|
||||
|
||||
echo '🔍 Finding issues missing area labels...'
|
||||
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔍 Finding issues missing kind labels...'
|
||||
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🏷️ Finding issues missing priority labels...'
|
||||
NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
name: '🔒 Gemini Scheduled Stale Issue Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
close-stale-issues:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Process Stale Issues'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
if (dryRun) {
|
||||
core.info('DRY RUN MODE ENABLED: No changes will be applied.');
|
||||
}
|
||||
const batchLabel = 'Stale';
|
||||
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const tenDaysAgo = new Date();
|
||||
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
||||
|
||||
core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`);
|
||||
core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`);
|
||||
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`;
|
||||
core.info(`Searching with query: ${query}`);
|
||||
|
||||
const itemsToCheck = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
sort: 'created',
|
||||
order: 'asc',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
core.info(`Found ${itemsToCheck.length} open issues to check.`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
for (const issue of itemsToCheck) {
|
||||
const createdAt = new Date(issue.created_at);
|
||||
const updatedAt = new Date(issue.updated_at);
|
||||
const reactionCount = issue.reactions.total_count;
|
||||
|
||||
// Basic thresholds
|
||||
if (reactionCount >= 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if it has a maintainer, help wanted, or Public Roadmap label
|
||||
const rawLabels = issue.labels.map((l) => l.name);
|
||||
const lowercaseLabels = rawLabels.map((l) => l.toLowerCase());
|
||||
if (
|
||||
lowercaseLabels.some((l) => l.includes('maintainer')) ||
|
||||
lowercaseLabels.includes('help wanted') ||
|
||||
rawLabels.includes('🗓️ Public Roadmap')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isStale = updatedAt < tenDaysAgo;
|
||||
|
||||
// If apparently active, check if it's only bot activity
|
||||
if (!isStale) {
|
||||
try {
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const lastHumanComment = comments.data.find(comment => comment.user.type !== 'Bot');
|
||||
if (lastHumanComment) {
|
||||
isStale = new Date(lastHumanComment.created_at) < tenDaysAgo;
|
||||
} else {
|
||||
// No human comments. Check if creator is human.
|
||||
if (issue.user.type !== 'Bot') {
|
||||
isStale = createdAt < tenDaysAgo;
|
||||
} else {
|
||||
isStale = true; // Bot created, only bot comments
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to fetch comments for issue #${issue.number}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
processedCount++;
|
||||
const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`;
|
||||
core.info(message);
|
||||
|
||||
if (!dryRun) {
|
||||
// Add label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [batchLabel]
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!'
|
||||
});
|
||||
|
||||
// Close issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`\nTotal issues processed: ${processedCount}`);
|
||||
@@ -1,254 +0,0 @@
|
||||
name: 'Gemini Scheduled Stale PR Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Every day at 2 AM UTC
|
||||
pull_request:
|
||||
types: ['opened', 'edited']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Process Stale PRs'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: team_slug
|
||||
});
|
||||
for (const m of members) maintainerLogins.add(m.login.toLowerCase());
|
||||
core.info(`Successfully fetched ${members.length} team members from ${team_slug}`);
|
||||
} catch (e) {
|
||||
// Silently skip if permissions are insufficient; we will rely on author_association
|
||||
core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMaintainer = async (login, assoc) => {
|
||||
// Reliably identify maintainers using authorAssociation (provided by GitHub)
|
||||
// and organization membership (if available).
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
if (isTeamMember || isRepoMaintainer) return true;
|
||||
|
||||
// Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission)
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({ org: org, username: login });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Gracefully ignore failures here
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. Fetch all open PRs
|
||||
let prs = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
prs = [pr];
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
}
|
||||
|
||||
for (const pr of prs) {
|
||||
const maintainerPr = await isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
if (maintainerPr || isBot) continue;
|
||||
|
||||
// Helper: Fetch labels and linked issues via GraphQL
|
||||
const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let linkedIssues = [];
|
||||
try {
|
||||
const res = await github.graphql(prDetailsQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// Check for mentions in body as fallback (regex)
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const matches = body.match(mentionRegex);
|
||||
if (matches && linkedIssues.length === 0) {
|
||||
const issueNumber = parseInt(matches[1]);
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }];
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 3. Enforcement Logic
|
||||
const prLabels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const hasHelpWanted = prLabels.includes('help wanted') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted'));
|
||||
|
||||
const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only'));
|
||||
|
||||
const hasLinkedIssue = linkedIssues.length > 0;
|
||||
|
||||
// Closure Policy: No help-wanted label = Close after 14 days
|
||||
if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) {
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
|
||||
// We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM
|
||||
if (prCreatedAt > fourteenDaysAgo) {
|
||||
core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check for linked issue even if it has help wanted (redundant but safe)
|
||||
if (pr.state === 'open' && !hasLinkedIssue) {
|
||||
// Already covered by hasHelpWanted check above, but good for future-proofing
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Staleness Check (Scheduled only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) continue;
|
||||
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (await isMaintainer(r.user.login, r.author_association)) {
|
||||
const d = new Date(r.submitted_at || r.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number
|
||||
});
|
||||
for (const c of comments) {
|
||||
if (await isMaintainer(c.user.login, c.author_association)) {
|
||||
const d = new Date(c.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only');
|
||||
if (isProtected) {
|
||||
core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
name: 'No Response'
|
||||
|
||||
# Run as a daily cron at 1:45 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '45 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-no-response'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
days-before-stale: -1
|
||||
days-before-close: 14
|
||||
stale-issue-label: 'status/need-information'
|
||||
close-issue-message: >-
|
||||
This issue was marked as needing more information and has not received a response in 14 days.
|
||||
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
|
||||
stale-pr-label: 'status/need-information'
|
||||
close-pr-message: >-
|
||||
This pull request was marked as needing more information and has had no updates in 14 days.
|
||||
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!
|
||||
@@ -1,133 +0,0 @@
|
||||
name: '🏷️ PR Contribution Guidelines Notifier'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- 'opened'
|
||||
|
||||
jobs:
|
||||
notify-process-change:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Check membership and post comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |-
|
||||
const org = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const username = context.payload.pull_request.user.login;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
|
||||
// 1. Check if the PR author is a maintainer
|
||||
// Check team membership (most reliable for private org members)
|
||||
let isTeamMember = false;
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: org,
|
||||
team_slug: team_slug
|
||||
});
|
||||
if (members.some(m => m.login.toLowerCase() === username.toLowerCase())) {
|
||||
isTeamMember = true;
|
||||
core.info(`${username} is a member of ${team_slug}. No notification needed.`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isTeamMember) return;
|
||||
|
||||
// Check author_association from webhook payload
|
||||
const authorAssociation = context.payload.pull_request.author_association;
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation);
|
||||
|
||||
if (isRepoMaintainer) {
|
||||
core.info(`${username} is a maintainer (author_association: ${authorAssociation}). No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if author is a Googler
|
||||
const isGoogler = async (login) => {
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: org,
|
||||
username: login
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to check org membership for ${login}: ${e.message}`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await isGoogler(username)) {
|
||||
core.info(`${username} is a Googler. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if the PR is already associated with an issue
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = { owner: org, repo: repo, number: pr_number };
|
||||
const result = await github.graphql(query, variables);
|
||||
const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||
|
||||
if (issueCount > 0) {
|
||||
core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Post the notification comment
|
||||
core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`);
|
||||
|
||||
const comment = `
|
||||
Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.
|
||||
|
||||
We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706).
|
||||
|
||||
Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.
|
||||
|
||||
Thank you for your understanding and for being a part of our community!
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: org,
|
||||
repo: repo,
|
||||
issue_number: pr_number,
|
||||
body: comment
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 'Mark stale issues and pull requests'
|
||||
|
||||
# Run as a daily cron at 1:30 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner:
|
||||
- 'ubuntu-latest' # GitHub-hosted
|
||||
runs-on: '${{ matrix.runner }}'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-stale'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
stale-issue-message: >-
|
||||
This issue has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
stale-pr-message: >-
|
||||
This pull request has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
close-issue-message: >-
|
||||
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
|
||||
close-pr-message: >-
|
||||
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
+42
-1
@@ -1,3 +1,44 @@
|
||||
# ---- Stage 1: Builder ----
|
||||
FROM docker.io/library/node:20-slim AS builder
|
||||
|
||||
# Install git (needed by generate-git-commit-info.js script)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy only package.json files first for better layer caching
|
||||
# Dependencies only re-install when package files change, not source files
|
||||
COPY package*.json ./
|
||||
COPY packages/cli/package*.json ./packages/cli/
|
||||
COPY packages/core/package*.json ./packages/core/
|
||||
COPY packages/vscode-ide-companion/package*.json ./packages/vscode-ide-companion/
|
||||
COPY packages/vscode-ide-companion/scripts/ ./packages/vscode-ide-companion/scripts/
|
||||
COPY packages/devtools/package*.json ./packages/devtools/
|
||||
COPY packages/sdk/package*.json ./packages/sdk/
|
||||
COPY packages/test-utils/package*.json ./packages/test-utils/
|
||||
COPY packages/a2a-server/package*.json ./packages/a2a-server/
|
||||
|
||||
# Use npm ci for consistent, reliable builds (respects package-lock.json)
|
||||
RUN HUSKY=0 npm ci --ignore-scripts
|
||||
|
||||
# Now copy the rest of the source (after install for better caching)
|
||||
COPY packages/ ./packages/
|
||||
COPY tsconfig*.json ./
|
||||
COPY eslint.config.js ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY esbuild.config.js ./
|
||||
|
||||
# Pass git commit hash as build arg instead of copying entire .git directory
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# Build and pack artifacts
|
||||
RUN HUSKY=0 npm run build && \
|
||||
npm pack -w packages/core --pack-destination packages/core/dist/ && \
|
||||
npm pack -w packages/cli --pack-destination packages/cli/dist/
|
||||
|
||||
# ---- Stage 2: Runtime ----
|
||||
FROM docker.io/library/node:20-slim
|
||||
|
||||
ARG SANDBOX_NAME="gemini-cli-sandbox"
|
||||
@@ -50,4 +91,4 @@ RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
# default entrypoint when none specified
|
||||
CMD ["gemini"]
|
||||
ENTRYPOINT ["/usr/local/share/npm-global/bin/gemini"]
|
||||
@@ -158,6 +158,7 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` |
|
||||
| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
@@ -166,7 +167,7 @@ they appear in the UI.
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
|
||||
| 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"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for 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` |
|
||||
@@ -177,7 +178,7 @@ they appear in the UI.
|
||||
| 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` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `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` |
|
||||
|
||||
|
||||
@@ -1752,6 +1752,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
["DEBUG", "DEBUG_MODE"]
|
||||
```
|
||||
|
||||
- **`advanced.ignoreLocalEnv`** (boolean):
|
||||
- **Description:** Whether to ignore generic .env files in the project
|
||||
directory.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`advanced.bugCommand`** (object):
|
||||
- **Description:** Configuration for the bug report command.
|
||||
- **Default:** `undefined`
|
||||
@@ -1774,7 +1780,9 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **Description:** The backend to use for voice transcription. Note: When
|
||||
using the Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
@@ -1925,8 +1933,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
- **Description:** Automatically extract reusable skills from past sessions in
|
||||
the background. Review results with /memory inbox.
|
||||
- **Description:** Automatically extract memory patches and skills from past
|
||||
sessions in the background. Every change is written as a unified diff
|
||||
`.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review
|
||||
in /memory inbox; nothing is applied until you approve it.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
Our release flows support both `dev` and `prod` environments.
|
||||
|
||||
The `dev` environment pushes to a private Github-hosted NPM repository, with the
|
||||
The `dev` environment pushes to a private GitHub-hosted NPM repository, with the
|
||||
package names beginning with `@google-gemini/**` instead of `@google/**`.
|
||||
|
||||
The `prod` environment pushes to the public global NPM registry via Wombat
|
||||
@@ -20,7 +20,7 @@ More information can be found about these systems in the
|
||||
|
||||
### Package scopes
|
||||
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (Github Private NPM Repo) |
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (GitHub Private NPM Repo) |
|
||||
| ---------- | ----------------------------- | ----------------------------------------- |
|
||||
| CLI | @google/gemini-cli | @google-gemini/gemini-cli |
|
||||
| Core | @google/gemini-cli-core | @google-gemini/gemini-cli-core A2A Server |
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-LLM evals that pin down the auto-memory inbox contract:
|
||||
* 1. Canonical filename — agent uses `.inbox/<kind>/extraction.patch`.
|
||||
* 2. Incremental merge — agent rewrites an existing extraction.patch
|
||||
* instead of creating new patch files alongside.
|
||||
* 3. Absolute-path pointers — when the agent creates a sibling .md, the
|
||||
* paired MEMORY.md hunk references it by absolute path.
|
||||
* 4. Project-root protection — agent never writes to
|
||||
* `<projectRoot>/GEMINI.md` even when content is team-shared.
|
||||
*
|
||||
* Each test seeds session transcripts with strong, consistent signal so the
|
||||
* extraction agent will reasonably produce SOME output (or, in the human-only
|
||||
* test, refrain from producing output that targets forbidden paths). Tests
|
||||
* are USUALLY_PASSES policy because LLM behavior is stochastic; the harness
|
||||
* already retries up to 3 times.
|
||||
*/
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
/** Minutes ago the session ended (must be ≥ 180 to clear the idle gate). */
|
||||
timestampOffsetMinutes: number;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'auto-memory-contract-eval',
|
||||
private: true,
|
||||
scripts: { build: 'echo build', test: 'echo test' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'README.md': '# Auto Memory Contract Eval\n\nFixture workspace.\n',
|
||||
};
|
||||
|
||||
const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
experimentalAutoMemory: true,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
|
||||
return userTurns.flatMap((text, index) => [
|
||||
{
|
||||
id: `u${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'user',
|
||||
content: [{ text }],
|
||||
},
|
||||
{
|
||||
id: `a${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Acknowledged.' }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
): Promise<void> {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionTimestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
|
||||
const conversation = {
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(chatsDir, filename),
|
||||
JSON.stringify(conversation, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InboxSnapshot {
|
||||
privateFiles: string[];
|
||||
globalFiles: string[];
|
||||
privateContents: Map<string, string>;
|
||||
}
|
||||
|
||||
async function snapshotInbox(config: Config): Promise<InboxSnapshot> {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inbox: InboxSnapshot = {
|
||||
privateFiles: [],
|
||||
globalFiles: [],
|
||||
privateContents: new Map(),
|
||||
};
|
||||
for (const kind of ['private', 'global'] as const) {
|
||||
const dir = path.join(memoryDir, '.inbox', kind);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fsp.readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const patchFiles = entries.filter((f) => f.endsWith('.patch')).sort();
|
||||
if (kind === 'private') {
|
||||
inbox.privateFiles = patchFiles;
|
||||
for (const fileName of patchFiles) {
|
||||
try {
|
||||
inbox.privateContents.set(
|
||||
fileName,
|
||||
await fsp.readFile(path.join(dir, fileName), 'utf-8'),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inbox.globalFiles = patchFiles;
|
||||
}
|
||||
}
|
||||
return inbox;
|
||||
}
|
||||
|
||||
describe('Auto Memory Contract', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses canonical extraction.patch filename when writing private memory',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-1',
|
||||
summary:
|
||||
'Confirm that this project verifies memory edits with `npm run verify:memory`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'For this project, every memory-system change is verified with `npm run verify:memory` before we hand the change back.',
|
||||
'That command is the gate. Without it the change is not considered done.',
|
||||
'It runs typechecks, the related unit tests, and a snapshot diff.',
|
||||
'Future agents working on memory should always run it after editing memoryService or commands/memory.ts.',
|
||||
'This is a durable rule for this project, not a one-off.',
|
||||
'The check is fast, under a minute, and failure means revert.',
|
||||
'Treat it as part of the memory subsystem contract.',
|
||||
'I want this remembered for next time.',
|
||||
'It applies to anything in packages/core/src/services/memoryService.ts and packages/core/src/commands/memory.ts.',
|
||||
'Make sure agents do not skip the verify step.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-2',
|
||||
summary: 'Same memory-verify command in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I had to remind the previous agent to run `npm run verify:memory` again.',
|
||||
'It is the durable verification command for memory edits in this repo.',
|
||||
'The agent forgot, even though we agreed last time.',
|
||||
'Please remember it for future memory-related work.',
|
||||
'It is the official verification step for memory changes.',
|
||||
'Run it whenever you touch memoryService.ts or commands/memory.ts.',
|
||||
'No exceptions. The command must finish green.',
|
||||
'This is a recurring rule across multiple sessions now.',
|
||||
'Make this part of your standard workflow for memory work.',
|
||||
'Verified again that the command catches regressions in MEMORY.md handling.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Either the agent extracted nothing (acceptable no-op) OR it extracted
|
||||
// exactly one canonical file per kind. Multiple files per kind violates
|
||||
// the contract.
|
||||
expect(inbox.privateFiles.length).toBeLessThanOrEqual(1);
|
||||
expect(inbox.globalFiles.length).toBeLessThanOrEqual(1);
|
||||
|
||||
// Strong assertion: when the agent DID write a private patch, it must
|
||||
// be the canonical filename.
|
||||
if (inbox.privateFiles.length === 1) {
|
||||
expect(inbox.privateFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
if (inbox.globalFiles.length === 1) {
|
||||
expect(inbox.globalFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'merges new findings into existing extraction.patch instead of creating new files',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
|
||||
await fsp.mkdir(inboxPrivate, { recursive: true });
|
||||
|
||||
// Pre-existing canonical patch left over from a prior session.
|
||||
const existingMemoryMd = path.join(memoryDir, 'MEMORY.md');
|
||||
const preExistingPatch = [
|
||||
`--- /dev/null`,
|
||||
`+++ ${existingMemoryMd}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory`,
|
||||
`+`,
|
||||
`+- This project lints with \`npm run lint\` (recurring rule from session 1).`,
|
||||
``,
|
||||
].join('\n');
|
||||
await fsp.writeFile(
|
||||
path.join(inboxPrivate, 'extraction.patch'),
|
||||
preExistingPatch,
|
||||
);
|
||||
|
||||
// New session that surfaces a different durable fact.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'incremental-typecheck-cmd',
|
||||
summary:
|
||||
'Confirm that typecheck for memory edits uses `npm run typecheck`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Always run `npm run typecheck` after editing any *.ts file in this repo.',
|
||||
'It is the standard typecheck command for the whole monorepo.',
|
||||
'Future agents should follow this without being reminded.',
|
||||
'It catches type errors before tests, much faster.',
|
||||
'Run it on every TypeScript edit, no exceptions.',
|
||||
'This is durable across the whole project.',
|
||||
'It is the project-wide convention for TS work.',
|
||||
'Make sure to run it after edits to memoryService.ts especially.',
|
||||
'It is fast and catches regressions early.',
|
||||
'Treat it as standard workflow.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Contract: still ONLY ONE file in private inbox, and its name is the
|
||||
// canonical extraction.patch.
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
// The single canonical patch must STILL contain the old hunk (the
|
||||
// agent must merge with existing rather than replace blindly), AND
|
||||
// ideally also contain the new typecheck fact.
|
||||
const merged = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
expect(merged).toMatch(/npm run lint/);
|
||||
// Soft assertion: the agent SHOULD have added the new fact too. We
|
||||
// don't fail the test if it didn't (the agent may legitimately decide
|
||||
// the new fact isn't durable enough), but the file must be intact.
|
||||
// The hard assertion (no proliferation + old content preserved) is
|
||||
// what we lock down.
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses absolute paths in MEMORY.md sibling pointer lines',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions whose extracted memory has substantial detail — encourages
|
||||
// the agent to spawn a sibling .md file (per prompt guidance).
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-1',
|
||||
summary: 'Detailed release workflow that runs across multiple steps',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Our release workflow has several distinct phases that future agents need to follow exactly.',
|
||||
'Phase 1 (preflight): run `npm run lint`, `npm run typecheck`, and `npm test` in that order.',
|
||||
'Phase 2 (build): run `npm run build` and verify dist/ outputs against a checksum file.',
|
||||
'Phase 3 (publish): run `npm run publish:dry-run` first, then `npm run publish` if no errors.',
|
||||
'Phase 4 (post): tag the commit with `git tag v$(jq -r .version package.json)` and push.',
|
||||
'There are pitfalls: phase 2 will silently succeed if dist/ is stale, so always check the checksum.',
|
||||
'Phase 3 must NEVER be skipped for hotfixes; the dry-run catches credential issues.',
|
||||
'The checklist is durable across all releases for this repo.',
|
||||
'Future agents should reproduce these phases in order without omitting any.',
|
||||
'This is the canonical release procedure for this project.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-2',
|
||||
summary: 'Reusing the same multi-phase release workflow',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I just ran the release workflow again and it caught an issue in phase 2 because the checksum mismatched.',
|
||||
'Confirms the durable rule: always check the dist/ checksum after building.',
|
||||
'The 4-phase release procedure (preflight, build, publish, post) is the recurring workflow.',
|
||||
'I want this captured as durable memory because we use it every release.',
|
||||
'Each phase has multiple sub-steps and pitfalls, so it deserves substantial detail.',
|
||||
'Please remember the phases for future agents.',
|
||||
'The procedure has been the same for the last 6 releases.',
|
||||
'It includes the verify-checksum step that just saved us from a bad publish.',
|
||||
'This is a recurring multi-step workflow, not a one-off.',
|
||||
'Make sure future sessions know about all 4 phases and their pitfalls.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
|
||||
// The agent might choose to add brief facts directly to MEMORY.md
|
||||
// without spawning a sibling. That's a valid outcome; we only enforce
|
||||
// the absolute-path rule WHEN a sibling is created.
|
||||
if (inbox.privateFiles.length === 0) {
|
||||
return; // No-op extraction: nothing to assert.
|
||||
}
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
const patch = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
|
||||
// Find any /dev/null sibling-creation hunk that targets <memoryDir>/<x>.md
|
||||
// (where x != MEMORY).
|
||||
const siblingPattern = new RegExp(
|
||||
`\\+\\+\\+ ${memoryDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}/([^\\s/]+)\\.md`,
|
||||
'g',
|
||||
);
|
||||
const siblingTargets: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = siblingPattern.exec(patch)) !== null) {
|
||||
const name = match[1];
|
||||
// Skip MEMORY.md updates (those aren't siblings).
|
||||
if (name.toLowerCase() !== 'memory') {
|
||||
siblingTargets.push(`${name}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
if (siblingTargets.length === 0) {
|
||||
return; // No sibling creations; nothing more to check.
|
||||
}
|
||||
|
||||
// For each created sibling, the patch must contain a MEMORY.md
|
||||
// pointer line that uses the ABSOLUTE path. Bare basename references
|
||||
// are the bug we're guarding against.
|
||||
for (const sibling of siblingTargets) {
|
||||
const absolutePath = path.join(memoryDir, sibling);
|
||||
// Look for an added line referencing the sibling.
|
||||
const addedLines = patch
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('+'));
|
||||
const referencingLines = addedLines.filter((line) =>
|
||||
line.includes(sibling),
|
||||
);
|
||||
expect(
|
||||
referencingLines.length,
|
||||
`Expected a MEMORY.md pointer for ${sibling} (auto-bundle would also add one).`,
|
||||
).toBeGreaterThan(0);
|
||||
const allAbsolute = referencingLines.every((line) =>
|
||||
line.includes(absolutePath),
|
||||
);
|
||||
expect(
|
||||
allAbsolute,
|
||||
`Pointer for ${sibling} must use absolute path. Saw: ${referencingLines.join(' | ')}`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'never writes to <projectRoot>/GEMINI.md even for team-shared facts',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions that talk about TEAM CONVENTIONS — the kind of content that
|
||||
// would be a perfect fit for <projectRoot>/GEMINI.md, but the prompt
|
||||
// forbids the extraction agent from touching it.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-1',
|
||||
summary: 'Team convention: always use pnpm not npm for installs',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Important team-wide convention for this repo: always use pnpm for installs, never npm.',
|
||||
'This is a shared rule across all engineers on the project.',
|
||||
'It applies to every package install, every clean, every dependency add.',
|
||||
'The rationale is workspace hoisting; npm would break the monorepo layout.',
|
||||
'This is a durable team rule, committed to the repo conventions.',
|
||||
'Future agents working in this repo should ALWAYS use pnpm.',
|
||||
'It is the standard team practice, no exceptions.',
|
||||
'Document it as part of the project conventions.',
|
||||
'Treat it as a hard rule for the team.',
|
||||
'I want this captured for future sessions.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-2',
|
||||
summary: 'Reaffirming the pnpm-only team rule in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'Reminder again: this team uses pnpm exclusively, never npm.',
|
||||
'Another agent tried npm install and broke the lockfile.',
|
||||
'The team rule is clear: pnpm only for any install operation.',
|
||||
'It is part of our shared conventions for this codebase.',
|
||||
'Make sure future agents follow this team-wide rule.',
|
||||
'It applies to all engineers, all CI runs, all dev environments.',
|
||||
'The convention is durable and well-established for this repo.',
|
||||
'Agents should read this rule from project conventions before installing.',
|
||||
'No future agent should ever invoke `npm install` in this repo.',
|
||||
'Always pnpm. Always.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
// No private patch should target <projectRoot>/GEMINI.md or any
|
||||
// subdirectory GEMINI.md.
|
||||
const projectRootRegex = new RegExp(
|
||||
`\\+\\+\\+ ${projectRoot.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}.*GEMINI\\.md`,
|
||||
);
|
||||
for (const [name, content] of inbox.privateContents) {
|
||||
expect(
|
||||
projectRootRegex.test(content),
|
||||
`Private patch "${name}" must not target a GEMINI.md under <projectRoot>. Content:\n${content}`,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Verify on disk: <projectRoot>/GEMINI.md was not created or modified
|
||||
// by the extraction agent (snapshot rollback should also enforce this,
|
||||
// but we double-check from the post-run state).
|
||||
const projectGemini = path.join(projectRoot, 'GEMINI.md');
|
||||
const exists = await fsp
|
||||
.access(projectGemini)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
// The seeded workspace's WORKSPACE_FILES doesn't include GEMINI.md, so
|
||||
// it must NOT exist after the run.
|
||||
expect(
|
||||
exists,
|
||||
`<projectRoot>/GEMINI.md (${projectGemini}) must not be created by the extraction agent.`,
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest';
|
||||
import { runEval } from './test-helper.js';
|
||||
import { SESSION_FILE_PREFIX } from '../packages/core/src/services/chatRecordingService.js';
|
||||
|
||||
const evalState = vi.hoisted(() => ({
|
||||
sessionFilePath: '',
|
||||
debugLines: [] as string[],
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
localAgentCreate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.js', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.ts', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.js', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.ts', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.ts', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockMemoryConfig {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => string;
|
||||
getProjectMemoryTempDir: () => string;
|
||||
getProjectSkillsMemoryDir: () => string;
|
||||
getProjectTempDir: () => string;
|
||||
getProjectRoot: () => string;
|
||||
};
|
||||
getTargetDir: () => string;
|
||||
getToolRegistry: () => unknown;
|
||||
getGeminiClient: () => unknown;
|
||||
getSkillManager: () => { getSkills: () => unknown[] };
|
||||
isAutoMemoryEnabled: () => boolean;
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
sandboxManager: undefined;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
rootDir: string;
|
||||
homeDir: string;
|
||||
targetDir: string;
|
||||
projectTempDir: string;
|
||||
memoryDir: string;
|
||||
skillsDir: string;
|
||||
config: MockMemoryConfig;
|
||||
}
|
||||
|
||||
interface AutoMemoryRunSnapshot {
|
||||
sessionIds?: string[];
|
||||
memoryCandidatesCreated?: string[];
|
||||
memoryFilesUpdated?: string[];
|
||||
skillsCreated?: string[];
|
||||
}
|
||||
|
||||
const fixtures: Fixture[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
evalState.debugLines = [];
|
||||
evalState.sessionFilePath = '';
|
||||
mocks.localAgentCreate.mockReset();
|
||||
mocks.localAgentCreate.mockImplementation(
|
||||
async (_agent, context, onActivity) => ({
|
||||
run: vi.fn().mockImplementation(async () => {
|
||||
if (evalState.sessionFilePath) {
|
||||
const callId = `read-inbox-routing`;
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
callId,
|
||||
args: { file_path: evalState.sessionFilePath },
|
||||
},
|
||||
});
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: { id: callId, data: { isError: false } },
|
||||
});
|
||||
}
|
||||
|
||||
const config = context.config as MockMemoryConfig;
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxDir = path.join(memoryDir, '.inbox');
|
||||
|
||||
const homeDir = process.env['GEMINI_CLI_HOME'] ?? os.homedir();
|
||||
const globalGeminiDir = path.join(homeDir, '.gemini');
|
||||
|
||||
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
|
||||
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
|
||||
|
||||
const privateTarget = path.join(memoryDir, 'verify-memory.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'private', 'verify-memory.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${privateTarget}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory Candidate`,
|
||||
`+`,
|
||||
`+Future agents should remember that this project verifies memory changes with \`npm run verify:memory\`.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const globalTarget = path.join(globalGeminiDir, 'GEMINI.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'global', 'reply-style.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${globalTarget}`,
|
||||
`@@ -0,0 +1,1 @@`,
|
||||
`+User prefers concise Chinese architecture plans.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return {
|
||||
turn_count: 3,
|
||||
duration_ms: 25,
|
||||
terminate_reason: 'GOAL',
|
||||
};
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (fixtures.length > 0) {
|
||||
const fixture = fixtures.pop();
|
||||
if (fixture) {
|
||||
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function autoMemoryEval(name: string, fn: () => Promise<void>): void {
|
||||
runEval(
|
||||
'USUALLY_PASSES',
|
||||
{
|
||||
suiteName: 'auto-memory-modes',
|
||||
suiteType: 'component-level',
|
||||
name,
|
||||
timeout: 30000,
|
||||
},
|
||||
fn,
|
||||
40000,
|
||||
);
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const rootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-auto-memory-eval-'),
|
||||
);
|
||||
const homeDir = path.join(rootDir, 'home');
|
||||
const targetDir = path.join(rootDir, 'workspace');
|
||||
const projectTempDir = path.join(rootDir, 'project-temp');
|
||||
const memoryDir = path.join(projectTempDir, 'memory');
|
||||
const skillsDir = path.join(memoryDir, 'skills');
|
||||
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.mkdir(path.join(projectTempDir, 'chats'), { recursive: true });
|
||||
vi.stubEnv('GEMINI_CLI_HOME', homeDir);
|
||||
|
||||
const config: MockMemoryConfig = {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => memoryDir,
|
||||
getProjectMemoryTempDir: () => memoryDir,
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectTempDir: () => projectTempDir,
|
||||
getProjectRoot: () => targetDir,
|
||||
},
|
||||
getTargetDir: () => targetDir,
|
||||
getToolRegistry: () => ({}),
|
||||
getGeminiClient: () => ({}),
|
||||
getSkillManager: () => ({ getSkills: () => [] }),
|
||||
isAutoMemoryEnabled: () => true,
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: vi.fn(),
|
||||
},
|
||||
sandboxManager: undefined,
|
||||
};
|
||||
|
||||
const fixture = {
|
||||
rootDir,
|
||||
homeDir,
|
||||
targetDir,
|
||||
projectTempDir,
|
||||
memoryDir,
|
||||
skillsDir,
|
||||
config,
|
||||
};
|
||||
fixtures.push(fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function seedSession(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<string> {
|
||||
const sessionFilePath = path.join(
|
||||
fixture.projectTempDir,
|
||||
'chats',
|
||||
`${SESSION_FILE_PREFIX}2026-04-20T10-00-${sessionId}.json`,
|
||||
);
|
||||
const oldTimestamp = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
|
||||
const messages = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `m${index + 1}`,
|
||||
timestamp: oldTimestamp,
|
||||
type: index % 2 === 0 ? 'user' : 'gemini',
|
||||
content: [
|
||||
{
|
||||
text:
|
||||
index % 2 === 0
|
||||
? 'For this project, durable memory changes are verified with `npm run verify:memory`.'
|
||||
: 'Acknowledged.',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFilePath,
|
||||
[
|
||||
{
|
||||
sessionId,
|
||||
projectHash: 'auto-memory-eval',
|
||||
summary: 'Capture durable auto memory routing behavior',
|
||||
startTime: oldTimestamp,
|
||||
lastUpdated: oldTimestamp,
|
||||
kind: 'main',
|
||||
},
|
||||
...messages,
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
return sessionFilePath;
|
||||
}
|
||||
|
||||
async function expectSeedSessionEligible(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const { buildSessionIndex } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const { newSessionIds } = await buildSessionIndex(
|
||||
path.join(fixture.projectTempDir, 'chats'),
|
||||
{ runs: [] },
|
||||
);
|
||||
expect(newSessionIds).toContain(sessionId);
|
||||
}
|
||||
|
||||
async function readRun(fixture: Fixture): Promise<AutoMemoryRunSnapshot> {
|
||||
const statePath = path.join(fixture.memoryDir, '.extraction-state.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(statePath, 'utf-8');
|
||||
} catch (error) {
|
||||
let memoryEntries = '(memory dir missing)';
|
||||
try {
|
||||
memoryEntries = (await fs.readdir(fixture.memoryDir, { recursive: true }))
|
||||
.map(String)
|
||||
.join('\n');
|
||||
} catch {
|
||||
// Leave default diagnostic.
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`Expected extraction state at ${statePath}.`,
|
||||
`LocalAgentExecutor.create calls: ${mocks.localAgentCreate.mock.calls.length}`,
|
||||
`Memory dir entries:\n${memoryEntries}`,
|
||||
`Debug log:\n${evalState.debugLines.join('\n')}`,
|
||||
].join('\n'),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: AutoMemoryRunSnapshot[];
|
||||
};
|
||||
const run = state.runs?.at(-1);
|
||||
if (!run) {
|
||||
throw new Error('Expected an auto memory extraction run to be recorded');
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auto Memory inbox routing', () => {
|
||||
autoMemoryEval(
|
||||
'every memory patch lands in .inbox/<kind>/ for review and active files stay untouched',
|
||||
async () => {
|
||||
const { startMemoryService } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const fixture = await createFixture();
|
||||
evalState.sessionFilePath = await seedSession(
|
||||
fixture,
|
||||
'inbox-routing-session',
|
||||
);
|
||||
await expectSeedSessionEligible(fixture, 'inbox-routing-session');
|
||||
|
||||
await startMemoryService(fixture.config as never);
|
||||
|
||||
const privatePatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'private',
|
||||
'verify-memory.patch',
|
||||
);
|
||||
const globalPatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'global',
|
||||
'reply-style.patch',
|
||||
);
|
||||
|
||||
const activePrivateMemoryPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'verify-memory.md',
|
||||
);
|
||||
const activeGlobalMemoryPath = path.join(
|
||||
fixture.homeDir,
|
||||
'.gemini',
|
||||
'GEMINI.md',
|
||||
);
|
||||
const run = await readRun(fixture);
|
||||
|
||||
// Both patches were written to the inbox.
|
||||
await expect(fs.readFile(privatePatchPath, 'utf-8')).resolves.toContain(
|
||||
'npm run verify:memory',
|
||||
);
|
||||
await expect(fs.readFile(globalPatchPath, 'utf-8')).resolves.toContain(
|
||||
'concise Chinese architecture plans',
|
||||
);
|
||||
|
||||
// No active file was touched — every patch must be reviewed manually.
|
||||
expect(await fileExists(activePrivateMemoryPath)).toBe(false);
|
||||
expect(await fileExists(activeGlobalMemoryPath)).toBe(false);
|
||||
|
||||
// Run state records both patches as candidates and zero applied files.
|
||||
expect(run.memoryFilesUpdated ?? []).toEqual([]);
|
||||
expect(run.memoryCandidatesCreated ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.relative(fixture.memoryDir, privatePatchPath),
|
||||
path.relative(fixture.memoryDir, globalPatchPath),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -66,7 +66,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
@@ -107,7 +106,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Simulate A2A client confirmation
|
||||
@@ -150,11 +148,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
// Simulate Rejection (Cancel)
|
||||
const handled = await (
|
||||
@@ -180,11 +174,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
correlationId: 'corr-2',
|
||||
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
|
||||
};
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
|
||||
|
||||
// Simulate ModifyWithEditor
|
||||
const handled2 = await (
|
||||
@@ -225,11 +215,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
// Simulate ProceedOnce for MCP
|
||||
const handled = await (
|
||||
@@ -269,11 +255,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -312,11 +294,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -355,11 +333,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -402,11 +376,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
|
||||
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
|
||||
@@ -449,7 +419,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Should publish artifact update for output
|
||||
@@ -484,11 +453,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
|
||||
// The tool should be complete and registered appropriately, eventually
|
||||
// triggering the toolCompletionPromise resolution when all clear.
|
||||
@@ -568,7 +533,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Confirm first tool call
|
||||
@@ -636,7 +600,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Should NOT transition to input-required yet
|
||||
@@ -658,7 +621,6 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1Complete, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Now it should transition
|
||||
|
||||
@@ -460,110 +460,4 @@ describe('Task', () => {
|
||||
expect(task.currentPromptId).toBe(expectedPromptId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Race Condition Fix', () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should NOT transition to input-required if a tool is still validating', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
// Manually register two tool calls
|
||||
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||
task['_registerToolCall']('tool-2', 'validating');
|
||||
|
||||
// Call checkInputRequiredState (private)
|
||||
task['checkInputRequiredState']();
|
||||
|
||||
// Verify task state did NOT change to input-required
|
||||
expect(task.taskState).not.toBe('input-required');
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: expect.objectContaining({ state: 'input-required' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transition to input-required if all active tools are awaiting approval', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
// Transition from submitted to working first to simulate normal flow
|
||||
task.taskState = 'working';
|
||||
|
||||
// Manually register tool calls
|
||||
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||
|
||||
// Call checkInputRequiredState
|
||||
task['checkInputRequiredState']();
|
||||
|
||||
// Verify task state changed to input-required
|
||||
expect(task.taskState).toBe('input-required');
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: expect.objectContaining({ state: 'input-required' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const handleEventDrivenToolCallSpy = vi.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task as any,
|
||||
'handleEventDrivenToolCall',
|
||||
);
|
||||
|
||||
const otherEvent = {
|
||||
type: 'tool-calls-update',
|
||||
toolCalls: [{ request: { callId: '1' }, status: 'executing' }],
|
||||
schedulerId: 'other-task-id',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task['handleEventDrivenToolCallsUpdate'](otherEvent as any);
|
||||
|
||||
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
|
||||
|
||||
const ownEvent = {
|
||||
type: 'tool-calls-update',
|
||||
toolCalls: [{ request: { callId: '1' }, status: 'executing' }],
|
||||
schedulerId: 'task-id',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task['handleEventDrivenToolCallsUpdate'](ownEvent as any);
|
||||
|
||||
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,10 +413,7 @@ export class Task {
|
||||
private handleEventDrivenToolCallsUpdate(
|
||||
event: ToolCallsUpdateMessage,
|
||||
): void {
|
||||
if (
|
||||
event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
|
||||
event.schedulerId !== this.id
|
||||
) {
|
||||
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -511,11 +508,7 @@ export class Task {
|
||||
let isExecuting = false;
|
||||
|
||||
for (const [callId, status] of this.pendingToolCalls.entries()) {
|
||||
if (
|
||||
status === 'executing' ||
|
||||
status === 'scheduled' ||
|
||||
status === 'validating'
|
||||
) {
|
||||
if (status === 'executing' || status === 'scheduled') {
|
||||
isExecuting = true;
|
||||
} else if (
|
||||
status === 'awaiting_approval' &&
|
||||
|
||||
@@ -381,11 +381,11 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// 6. Tool 1 is awaiting approval.
|
||||
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
|
||||
const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-confirmation',
|
||||
});
|
||||
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
|
||||
expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-1' },
|
||||
@@ -394,28 +394,14 @@ describe('E2E Tests', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// 7. Tool 2 is awaiting approval.
|
||||
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-confirmation',
|
||||
});
|
||||
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-2' },
|
||||
status: 'awaiting_approval',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 8. The final event is "input-required".
|
||||
const finalEvent = events[7].result as TaskStatusUpdateEvent;
|
||||
// 7. The final event is "input-required".
|
||||
const finalEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
expect(finalEvent.final).toBe(true);
|
||||
expect(finalEvent.status.state).toBe('input-required');
|
||||
|
||||
// The scheduler now waits for approval, so no more events are sent.
|
||||
assertUniqueFinalEventIsLast(events);
|
||||
expect(events.length).toBe(8);
|
||||
expect(events.length).toBe(7);
|
||||
});
|
||||
|
||||
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxMemoryPatches,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listMemoryFiles,
|
||||
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
|
||||
export class InboxMemoryCommand implements Command {
|
||||
readonly name = 'memory inbox';
|
||||
readonly description =
|
||||
'Lists skills extracted from past sessions that are pending review.';
|
||||
'Lists memory items extracted from past sessions that are pending review.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
|
||||
};
|
||||
}
|
||||
|
||||
const [skills, patches] = await Promise.all([
|
||||
const [skills, patches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(context.agentContext.config),
|
||||
listInboxPatches(context.agentContext.config),
|
||||
listInboxMemoryPatches(context.agentContext.config),
|
||||
]);
|
||||
|
||||
if (skills.length === 0 && patches.length === 0) {
|
||||
if (
|
||||
skills.length === 0 &&
|
||||
patches.length === 0 &&
|
||||
memoryPatches.length === 0
|
||||
) {
|
||||
return { name: this.name, data: 'No items in inbox.' };
|
||||
}
|
||||
|
||||
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
|
||||
: '';
|
||||
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
|
||||
}
|
||||
for (const memoryPatch of memoryPatches) {
|
||||
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = memoryPatch.extractedAt
|
||||
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
const sourceCount = memoryPatch.sourceFiles.length;
|
||||
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
|
||||
lines.push(
|
||||
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = skills.length + patches.length;
|
||||
const total = skills.length + patches.length + memoryPatches.length;
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
|
||||
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(tempWorkspaceDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -1174,6 +1174,20 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
});
|
||||
|
||||
const argv = await parseArguments(settings);
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
|
||||
@@ -560,6 +560,7 @@ export interface LoadCliConfigOptions {
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
skipMemoryLoad?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -568,7 +569,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
skipMemoryLoad = false,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -681,7 +687,7 @@ export async function loadCliConfig(
|
||||
const finalExtensionLoader =
|
||||
extensionManager ?? new SimpleExtensionLoader([]);
|
||||
|
||||
if (!experimentalJitContext) {
|
||||
if (!experimentalJitContext && !skipMemoryLoad) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up files if needed, or rely on temp dir cleanup
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(currentTempHome);
|
||||
await cleanupTmpDir(tempWorkspace);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -149,6 +149,35 @@ describe('consent', () => {
|
||||
expect(consent).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should clear the active confirmation request before resolving', async () => {
|
||||
const clearConfirmationRequest = vi.fn();
|
||||
const steps: string[] = [];
|
||||
const addExtensionUpdateConfirmationRequest = vi
|
||||
.fn()
|
||||
.mockImplementation((request: ConfirmationRequest) => {
|
||||
steps.push('prompted');
|
||||
request.onConfirm(true);
|
||||
steps.push('confirmed');
|
||||
});
|
||||
|
||||
const consentPromise = requestConsentInteractive(
|
||||
'Test consent',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
() => {
|
||||
steps.push('cleared');
|
||||
clearConfirmationRequest();
|
||||
},
|
||||
).then((consent) => {
|
||||
steps.push('resolved');
|
||||
return consent;
|
||||
});
|
||||
|
||||
expect(clearConfirmationRequest).toHaveBeenCalledTimes(1);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed']);
|
||||
await expect(consentPromise).resolves.toBe(true);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeRequestConsentOrFail', () => {
|
||||
|
||||
@@ -78,10 +78,12 @@ export async function requestConsentNonInteractive(
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
clearConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,12 +131,14 @@ export async function promptForConsentNonInteractive(
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
clearConfirmationRequest?.();
|
||||
setImmediate(() => resolve(resolvedConfirmed));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type * as osActual from 'node:os';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { loadEnvironment, type Settings } from './settings.js';
|
||||
import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Environment Isolation', () => {
|
||||
const mockHome = path.resolve('/mock/home');
|
||||
const mockWorkspace = path.resolve('/mock/workspace');
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
||||
vi.mocked(coreHomedir).mockReturnValue(mockHome);
|
||||
// Default to no files existing
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
process.argv = ['node', 'gemini'];
|
||||
|
||||
// Clear env vars that might leak from the host environment
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['OTHER_VAR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should load local .env by default', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should ignore local .env when ignoreLocalEnv is true', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === workspaceEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
// Should skip local and find home
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
|
||||
const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceGeminiEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect --ignore-env flag', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow home .env even with ignoreLocalEnv true', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from home dir
|
||||
loadEnvironment(settings, mockHome, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
|
||||
const deepProject = path.join(mockWorkspace, 'deep', 'dir');
|
||||
const deepEnv = path.join(deepProject, '.env');
|
||||
const parentEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === deepEnv || ps === parentEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
|
||||
if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, deepProject, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect trust whitelist even when loading from home .env', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
// Include one whitelisted and one non-whitelisted variable
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GEMINI_API_KEY=home\nOTHER_VAR=secret',
|
||||
);
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from an UNTRUSTED workspace
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
expect(process.env['OTHER_VAR']).toBeUndefined();
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should prioritize --ignore-env flag even if setting is false', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect both -s and --ignore-env flags simultaneously', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '-s', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -500,7 +500,11 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(
|
||||
startDir: string,
|
||||
isTrusted: boolean,
|
||||
ignoreLocalEnv: boolean,
|
||||
): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
@@ -512,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
if (!ignoreLocalEnv || currentDir === homedir()) {
|
||||
return envPath;
|
||||
}
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
@@ -595,7 +601,6 @@ export function loadEnvironment(
|
||||
): void {
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
@@ -612,6 +617,12 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
const shouldIgnoreEnv =
|
||||
!!settings.advanced?.ignoreLocalEnv ||
|
||||
relevantArgs.includes('--ignore-env');
|
||||
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
const selectedAuthType = settings.security?.auth?.selectedType;
|
||||
|
||||
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ignoreLocalEnv: {
|
||||
type: 'boolean',
|
||||
label: 'Ignore Local .env',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ignore generic .env files in the project directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
bugCommand: {
|
||||
type: 'object',
|
||||
label: 'Bug Command',
|
||||
@@ -2099,7 +2109,11 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
description: oneLine`
|
||||
The backend to use for voice transcription. Note: When using the
|
||||
Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
@@ -2406,7 +2420,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
|
||||
'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generalistProfile: {
|
||||
|
||||
@@ -412,6 +412,7 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -829,7 +830,7 @@ export function initializeOutputListenersAndFlush(config?: Config) {
|
||||
}
|
||||
|
||||
const outputFormat = config?.getOutputFormat();
|
||||
const forceToStderr = outputFormat === 'json' || config === undefined;
|
||||
const forceToStderr = outputFormat === 'json';
|
||||
|
||||
coreEvents.drainBacklogs(
|
||||
<K extends keyof CoreEvents>(event: K, args: CoreEvents[K]) => {
|
||||
|
||||
@@ -69,16 +69,16 @@ describe('Output Redirection', () => {
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should force stdout to stderr when config is undefined (early failure)', () => {
|
||||
it('should NOT force stdout to stderr when config is undefined (early init/version)', () => {
|
||||
// Simulate buffered output during early init
|
||||
coreEvents.emitOutput(false, 'early init message');
|
||||
|
||||
// Initialize with undefined config
|
||||
initializeOutputListenersAndFlush(undefined);
|
||||
|
||||
// Verify it was forced to stderr
|
||||
expect(writeToStderr).toHaveBeenCalledWith('early init message', undefined);
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
// Verify it went to stdout (default behavior)
|
||||
expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined);
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
|
||||
|
||||
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
|
||||
agentsCommand: { name: 'agents' },
|
||||
}));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
|
||||
bugMemoryCommand: { name: 'bug-memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({
|
||||
chatCommand: {
|
||||
name: 'chat',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
|
||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
||||
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||
authCommand,
|
||||
bugCommand,
|
||||
bugMemoryCommand,
|
||||
{
|
||||
...chatCommand,
|
||||
subCommands: chatResumeSubCommands,
|
||||
|
||||
@@ -110,7 +110,15 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 3,
|
||||
localCount: 2,
|
||||
remoteCount: 1,
|
||||
newAgents: ['new-agent'],
|
||||
updatedAgents: ['updated-agent'],
|
||||
deletedAgents: ['deleted-agent'],
|
||||
errors: [],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
@@ -120,7 +128,10 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
@@ -132,8 +143,42 @@ describe('agentsCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: expect.stringContaining('Agents reloaded successfully:'),
|
||||
});
|
||||
expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
|
||||
expect(result.content).toContain('- New: new-agent');
|
||||
expect(result.content).toContain('- Updated: updated-agent');
|
||||
expect(result.content).toContain('- Deleted: deleted-agent');
|
||||
expect(result.content).toContain(
|
||||
'Run /agents list to see all available agents.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "reloaded with errors" if errors occurred during reload', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 1,
|
||||
localCount: 1,
|
||||
remoteCount: 0,
|
||||
newAgents: [],
|
||||
updatedAgents: [],
|
||||
deletedAgents: [],
|
||||
errors: ['Some error'],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
);
|
||||
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(result.content).toContain('Agents reloaded with errors:');
|
||||
expect(result.content).toContain('- Errors: 1 encountered during reload');
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
|
||||
@@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = {
|
||||
text: 'Reloading agent registry...',
|
||||
});
|
||||
|
||||
await agentRegistry.reload();
|
||||
const summary = await agentRegistry.reload();
|
||||
|
||||
let content =
|
||||
summary.errors.length > 0
|
||||
? 'Agents reloaded with errors:'
|
||||
: 'Agents reloaded successfully:';
|
||||
content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
|
||||
|
||||
if (summary.newAgents.length > 0) {
|
||||
content += `\n- New: ${summary.newAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.updatedAgents.length > 0) {
|
||||
content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.deletedAgents.length > 0) {
|
||||
content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.errors.length > 0) {
|
||||
content += `\n- Errors: ${summary.errors.length} encountered during reload`;
|
||||
}
|
||||
|
||||
content += '\n\nRun /agents list to see all available agents.';
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
||||
import { getVersion, type Config } from '@google/gemini-cli-core';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
const { memoryUsageMock } = vi.hoisted(() => ({
|
||||
memoryUsageMock: vi.fn(() => ({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 4096 }),
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
memoryUsage: memoryUsageMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
const buildHighMemoryContext = (tempDir: string | undefined) =>
|
||||
createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => false,
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: { getChat: () => ({ getHistory: () => [] }) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = buildHighMemoryContext(tempDir);
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A memory bug');
|
||||
|
||||
const now = new Date('2024-01-01T00:00:00Z').getTime();
|
||||
const expectedSnapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${now}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem);
|
||||
const callOrder = addItem.mock.invocationCallOrder;
|
||||
const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
|
||||
// The URL message must precede the "capturing" message so the user sees
|
||||
// the URL before the 20+ second snapshot starts.
|
||||
expect(callOrder[0]).toBeLessThan(openOrder);
|
||||
expect(callOrder[1]).toBeGreaterThan(openOrder);
|
||||
expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
|
||||
expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
|
||||
expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
|
||||
});
|
||||
|
||||
it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 1 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A light bug');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error if the auto-capture fails but does not throw', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector failure'),
|
||||
);
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await expect(
|
||||
bugCommand.action(context, 'A memory bug'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItem[addItem.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||
import {
|
||||
captureHeapSnapshot,
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
|
||||
} from '../utils/memorySnapshot.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
const rss = process.memoryUsage().rss;
|
||||
const tempDir = config?.storage?.getProjectTempDir();
|
||||
if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
|
||||
const snapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await captureHeapSnapshot(snapshotPath);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(snapshotPath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(
|
||||
`Failed to capture heap snapshot for bug report: ${errorMessage}`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { bugMemoryCommand } from './bugMemoryCommand.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 1234 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeContextWithTempDir(tempDir: string | undefined) {
|
||||
return createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
} as unknown as Config,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('bugMemoryCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('declares itself as a non-auto-executing built-in command', () => {
|
||||
expect(bugMemoryCommand.name).toBe('bug-memory');
|
||||
expect(bugMemoryCommand.autoExecute).toBe(false);
|
||||
expect(bugMemoryCommand.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('captures a heap snapshot and reports the file path', async () => {
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = makeContextWithTempDir(tempDir);
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const expectedPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(2);
|
||||
expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[0][0].text).toContain(expectedPath);
|
||||
expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItemCalls[1][0].text).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('surfaces an error if capture fails', async () => {
|
||||
const context = makeContextWithTempDir('/tmp/gemini-test');
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector disconnected'),
|
||||
);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItemCalls[addItemCalls.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector disconnected');
|
||||
});
|
||||
|
||||
it('emits an error when no project temp directory is available', async () => {
|
||||
const context = makeContextWithTempDir(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(1);
|
||||
expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
|
||||
expect(addItemCalls[0][0].text).toContain('temp directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
export const bugMemoryCommand: SlashCommand = {
|
||||
name: 'bug-memory',
|
||||
description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const tempDir =
|
||||
context.services.agentContext?.config?.storage?.getProjectTempDir();
|
||||
if (!tempDir) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
const rss = process.memoryUsage().rss;
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await captureHeapSnapshot(filePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(`Failed to capture heap snapshot: ${message}`);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
import { InboxDialog } from '../components/InboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
component: React.createElement(InboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
onReloadMemory: async () => {
|
||||
await refreshMemory(config);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
import { requestConsentInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -253,6 +254,36 @@ describe('skillsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass a cleanup callback for interactive workspace consent', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
context.ui.setConfirmationRequest = vi.fn();
|
||||
vi.mocked(linkSkill).mockImplementation(
|
||||
async (_sourcePath, _scope, _addItem, requestConsent) => {
|
||||
expect(requestConsent).toBeDefined();
|
||||
await requestConsent!(
|
||||
[{ name: 'test-skill', location: '/path' } as SkillDefinition],
|
||||
'/workspace/.gemini/skills',
|
||||
);
|
||||
return [{ name: 'test-skill', location: '/path' }];
|
||||
},
|
||||
);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
const requestConsentCall = vi
|
||||
.mocked(requestConsentInteractive)
|
||||
.mock.calls.at(-1);
|
||||
expect(requestConsentCall?.[1]).toEqual(expect.any(Function));
|
||||
|
||||
const clearConfirmationRequest = requestConsentCall?.[2];
|
||||
expect(clearConfirmationRequest).toBeTypeOf('function');
|
||||
|
||||
clearConfirmationRequest?.();
|
||||
expect(context.ui.setConfirmationRequest).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
|
||||
@@ -118,6 +118,7 @@ async function linkAction(
|
||||
return requestConsentInteractive(
|
||||
consentString,
|
||||
context.ui.setConfirmationRequest.bind(context.ui),
|
||||
() => context.ui.setConfirmationRequest(null),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface CommandContext {
|
||||
*
|
||||
* @param value The confirmation request details.
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
setConfirmationRequest: (value: ConfirmationRequest | null) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
|
||||
+525
-14
@@ -6,19 +6,32 @@
|
||||
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
InboxSkill,
|
||||
InboxPatch,
|
||||
InboxMemoryPatch,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
dismissInboxMemoryPatch,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
moveInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
applyInboxMemoryPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { SkillInboxDialog } from './SkillInboxDialog.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { InboxDialog } from './InboxDialog.js';
|
||||
|
||||
const altBufferSettings = createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -27,11 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
dismissInboxMemoryPatch: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
listInboxPatches: vi.fn(),
|
||||
listInboxMemoryPatches: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
applyInboxPatch: vi.fn(),
|
||||
dismissInboxPatch: vi.fn(),
|
||||
applyInboxMemoryPatch: vi.fn(),
|
||||
isProjectSkillPatchTarget: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -41,10 +57,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
||||
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
||||
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
||||
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
|
||||
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
|
||||
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
@@ -76,6 +95,27 @@ const inboxPatch: InboxPatch = {
|
||||
extractedAt: '2025-01-20T14:00:00Z',
|
||||
};
|
||||
|
||||
const inboxMemoryPatch: InboxMemoryPatch = {
|
||||
kind: 'private',
|
||||
relativePath: 'private',
|
||||
name: 'Private memory',
|
||||
sourceFiles: ['update-memory.patch'],
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
isNewFile: false,
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-old',
|
||||
'+use focused tests',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
extractedAt: '2025-01-21T10:00:00Z',
|
||||
};
|
||||
|
||||
const workspacePatch: InboxPatch = {
|
||||
fileName: 'workspace-update.patch',
|
||||
name: 'workspace-update',
|
||||
@@ -137,11 +177,12 @@ const windowsGlobalPatch: InboxPatch = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
describe('InboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
@@ -158,6 +199,14 @@ describe('SkillInboxDialog', () => {
|
||||
success: true,
|
||||
message: 'Dismissed "update-docs.patch" from inbox.',
|
||||
});
|
||||
mockApplyInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Applied memory patch to 1 file.',
|
||||
});
|
||||
mockDismissInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed 1 private memory patch from inbox.',
|
||||
});
|
||||
mockIsProjectSkillPatchTarget.mockImplementation(
|
||||
async (targetPath: string, config: Config) => {
|
||||
const projectSkillsDir = config.storage
|
||||
@@ -176,6 +225,64 @@ describe('SkillInboxDialog', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('reviews and applies memory patches', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn()}
|
||||
onReloadMemory={onReloadMemory}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Private memory');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Review');
|
||||
expect(frame).toMatch(/source patch/);
|
||||
});
|
||||
|
||||
// Memory patches default to Dismiss as the highlighted action so a stray
|
||||
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
|
||||
// press Enter to confirm.
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // arrow down → Apply
|
||||
await waitUntilReady();
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Aggregate apply: relativePath equals the kind name.
|
||||
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
expect(onReloadMemory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
@@ -183,7 +290,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -228,7 +335,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -276,7 +383,7 @@ describe('SkillInboxDialog', () => {
|
||||
.mockRejectedValue(new Error('reload hook failed'));
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -316,6 +423,83 @@ describe('SkillInboxDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
|
||||
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
|
||||
// the list with focus jumped back to row 0 instead of staying on the row
|
||||
// the user was on.
|
||||
const secondSkill: InboxSkill = {
|
||||
...inboxSkill,
|
||||
dirName: 'second-skill',
|
||||
name: 'Second Skill',
|
||||
};
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Arrow down to the second row.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Enter the second row's preview.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Esc back to list.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Re-enter (no arrow keys this time). The active row must still be the
|
||||
// SECOND skill, not the first — which is what the bug reproduced before.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
// The preview header echoes the highlighted skill's name.
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('patch support', () => {
|
||||
it('shows patches alongside skills with section headers', async () => {
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
@@ -328,7 +512,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -360,7 +544,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -401,7 +585,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -449,7 +633,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -494,7 +678,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -538,7 +722,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -593,7 +777,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -628,7 +812,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -656,5 +840,332 @@ describe('SkillInboxDialog', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
|
||||
const tallPatch: InboxPatch = {
|
||||
fileName: 'tall.patch',
|
||||
name: 'tall-patch',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,4 +1,8 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+added-1',
|
||||
'+added-2',
|
||||
'+added-3',
|
||||
'+added-4',
|
||||
' line3',
|
||||
' line4',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
|
||||
// Alt-buffer mode has no terminal scrollback, so the dialog must
|
||||
// scroll inside itself. ScrollableList renders a `█` thumb when
|
||||
// content exceeds viewport height — the regression signal that the
|
||||
// diff is bounded and off-screen content is reachable via PgUp/PgDn.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{
|
||||
settings: altBufferSettings,
|
||||
uiState: { terminalHeight: 18 },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain('Dismiss');
|
||||
expect(frame).toContain('█');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ settings: altBufferSettings },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('update-docs');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('PgUp/PgDn to scroll');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
|
||||
// Non-alt-buffer mode uses the codebase's standard bounded
|
||||
// DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
|
||||
// FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
|
||||
// "... first/last N line(s) hidden ..." hint when it clips, which
|
||||
// is the regression signal that the diff is bounded.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18, constrainHeight: true } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame() ?? '').toMatch(/lines? hidden/);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
|
||||
// In non-alt-buffer mode the Ctrl+O affordance is rendered inline
|
||||
// by ShowMoreLines above the footer when the diff is clipped. The
|
||||
// footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
|
||||
// duplicating the hint there would be noisy.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18, constrainHeight: true } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Ctrl+O');
|
||||
expect(frame).not.toContain('PgUp/PgDn to scroll');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders each list row as exactly two lines even with long descriptions', async () => {
|
||||
// Reproduces the production bug: with the previous renderer, long
|
||||
// descriptions wrapped onto multiple lines (and the date sibling was
|
||||
// interleaved into the wrap), making each item 3-5 rows tall and
|
||||
// breaking the listMaxItemsToShow budget. The fix uses height={2}
|
||||
// and wrap="truncate-end" on every list row.
|
||||
const longDescription =
|
||||
'This is an extremely long description that would absolutely wrap to ' +
|
||||
'multiple lines if rendered without truncation, which used to push the ' +
|
||||
'list-phase footer off the bottom of the alternate buffer in production.';
|
||||
mockListInboxSkills.mockResolvedValue([
|
||||
{
|
||||
dirName: 'long-skill',
|
||||
name: 'long-skill',
|
||||
description: longDescription,
|
||||
content: '---\nname: x\ndescription: y\n---\n',
|
||||
},
|
||||
]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('long-skill');
|
||||
});
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).not.toContain('production');
|
||||
expect(frame).toContain('extremely long description');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps the list-phase footer on screen with many long-description skills', async () => {
|
||||
const longDesc =
|
||||
'A very long description that would wrap across multiple lines if not ' +
|
||||
'truncated, which was causing the dialog body to overflow the bottom ' +
|
||||
'of the alternate buffer';
|
||||
const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
|
||||
dirName: `skill-${i}`,
|
||||
name: `skill-${i}`,
|
||||
description: `${longDesc} (#${i})`,
|
||||
content: '---\nname: x\ndescription: y\n---\n',
|
||||
}));
|
||||
mockListInboxSkills.mockResolvedValue(manySkills);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 28 } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Memory Inbox');
|
||||
expect(frame).toContain('Esc to close');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps the list-phase footer on screen on short terminals', async () => {
|
||||
const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
dirName: `skill-${i}`,
|
||||
name: `Skill ${i}`,
|
||||
description: `Description ${i}`,
|
||||
content: '---\nname: Skill\ndescription: Skill\n---\n',
|
||||
}));
|
||||
mockListInboxSkills.mockResolvedValue(manySkills);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18 } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Memory Inbox');
|
||||
expect(frame).toContain('Esc to close');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,876 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxPatch,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
|
||||
|
||||
type InboxItem =
|
||||
| { type: 'skill'; skill: InboxSkill }
|
||||
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
|
||||
| { type: 'header'; label: string };
|
||||
|
||||
interface DestinationChoice {
|
||||
destination: InboxSkillDestination;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PatchAction {
|
||||
action: 'apply' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
label: 'Global',
|
||||
description: '~/.gemini/skills — available in all projects',
|
||||
},
|
||||
{
|
||||
destination: 'project',
|
||||
label: 'Project',
|
||||
description: '.gemini/skills — available in this workspace',
|
||||
},
|
||||
];
|
||||
|
||||
interface SkillPreviewAction {
|
||||
action: 'move' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
|
||||
{
|
||||
action: 'move',
|
||||
label: 'Move',
|
||||
description: 'Choose where to install this skill',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
const PATCH_ACTION_CHOICES: PatchAction[] = [
|
||||
{
|
||||
action: 'apply',
|
||||
label: 'Apply',
|
||||
description: 'Apply patch and delete from inbox',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox without applying',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathForUi(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
function getPathBasename(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
const basename = path.posix.basename(normalizedPath);
|
||||
return basename === '.' ? filePath : basename;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
patch: InboxPatch,
|
||||
config: Config,
|
||||
): Promise<boolean> {
|
||||
const entryTargetsProjectSkills = await Promise.all(
|
||||
patch.entries.map((entry) =>
|
||||
isProjectSkillPatchTarget(entry.targetPath, config),
|
||||
),
|
||||
);
|
||||
return entryTargetsProjectSkills.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a bracketed origin tag from a skill file path,
|
||||
* matching the existing [Built-in] convention in SkillsList.
|
||||
*/
|
||||
function getSkillOriginTag(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
|
||||
if (normalizedPath.includes('/bundle/')) {
|
||||
return 'Built-in';
|
||||
}
|
||||
if (normalizedPath.includes('/extensions/')) {
|
||||
return 'Extension';
|
||||
}
|
||||
if (normalizedPath.includes('/.gemini/skills/')) {
|
||||
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
|
||||
.filter((homeDir): homeDir is string => Boolean(homeDir))
|
||||
.map(normalizePathForUi);
|
||||
if (
|
||||
homeDirs.some((homeDir) =>
|
||||
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
|
||||
)
|
||||
) {
|
||||
return 'Global';
|
||||
}
|
||||
return 'Workspace';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unified diff string representing a new file.
|
||||
*/
|
||||
function newFileDiff(filename: string, content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const hunkLines = lines.map((l) => `+${l}`).join('\n');
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${filename}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
hunkLines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillInboxDialogProps {
|
||||
config: Config;
|
||||
onClose: () => void;
|
||||
onReloadSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
config,
|
||||
onClose,
|
||||
onReloadSkills,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 80;
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
const [phase, setPhase] = useState<Phase>('list');
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Load inbox skills and patches on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [skills, patches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
]);
|
||||
const patchItems = await Promise.all(
|
||||
patches.map(async (patch): Promise<InboxItem> => {
|
||||
let targetsProjectSkills = false;
|
||||
try {
|
||||
targetsProjectSkills = await patchTargetsProjectSkills(
|
||||
patch,
|
||||
config,
|
||||
);
|
||||
} catch {
|
||||
targetsProjectSkills = false;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'patch',
|
||||
patch,
|
||||
targetsProjectSkills,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
const combined: InboxItem[] = [
|
||||
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
||||
...patchItems,
|
||||
];
|
||||
setItems(combined);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const getItemKey = useCallback(
|
||||
(item: InboxItem): string =>
|
||||
item.type === 'skill'
|
||||
? `skill:${item.skill.dirName}`
|
||||
: item.type === 'patch'
|
||||
? `patch:${item.patch.fileName}`
|
||||
: `header:${item.label}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
|
||||
const skills = items.filter((i) => i.type === 'skill');
|
||||
const patches = items.filter((i) => i.type === 'patch');
|
||||
const result: Array<SelectionListItem<InboxItem>> = [];
|
||||
|
||||
// Only show section headers when both types are present
|
||||
const showHeaders = skills.length > 0 && patches.length > 0;
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'New Skills' };
|
||||
result.push({
|
||||
key: 'header:new-skills',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of skills) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
|
||||
result.push({
|
||||
key: 'header:skill-updates',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of patches) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, getItemKey]);
|
||||
|
||||
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
||||
() =>
|
||||
SKILL_DESTINATION_CHOICES.map((choice) => {
|
||||
if (choice.destination === 'project' && !isTrustedFolder) {
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder],
|
||||
);
|
||||
|
||||
const selectedPatchTargetsProjectSkills = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItem.targetsProjectSkills;
|
||||
}, [selectedItem]);
|
||||
|
||||
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
|
||||
() =>
|
||||
PATCH_ACTION_CHOICES.map((choice) => {
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
selectedPatchTargetsProjectSkills &&
|
||||
!isTrustedFolder
|
||||
) {
|
||||
return {
|
||||
key: choice.action,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder, selectedPatchTargetsProjectSkills],
|
||||
);
|
||||
|
||||
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
|
||||
useMemo(
|
||||
() =>
|
||||
SKILL_PREVIEW_CHOICES.map((choice) => ({
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback((item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
|
||||
);
|
||||
},
|
||||
[getItemKey],
|
||||
);
|
||||
|
||||
const handleSkillPreviewAction = useCallback(
|
||||
(choice: SkillPreviewAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
|
||||
if (choice.action === 'move') {
|
||||
setFeedback(null);
|
||||
setPhase('skill-action');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss
|
||||
setFeedback(null);
|
||||
const skill = selectedItem.skill;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dismissInboxSkill(config, skill.dirName);
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
if (result.success) {
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectDestination = useCallback(
|
||||
(choice: DestinationChoice) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
const skill = selectedItem.skill;
|
||||
|
||||
if (choice.destination === 'project' && !config.isTrustedFolder()) {
|
||||
setFeedback({
|
||||
text: 'Project skills are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await moveInboxSkill(
|
||||
config,
|
||||
skill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to install skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectPatchAction = useCallback(
|
||||
(choice: PatchAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') return;
|
||||
const patch = selectedItem.patch;
|
||||
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
!config.isTrustedFolder() &&
|
||||
selectedItem.targetsProjectSkills
|
||||
) {
|
||||
setFeedback({
|
||||
text: 'Project skill patches are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.action === 'apply') {
|
||||
result = await applyInboxPatch(config, patch.fileName);
|
||||
} else {
|
||||
result = await dismissInboxPatch(config, patch.fileName);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.action === 'apply') {
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (phase === 'skill-action') {
|
||||
setPhase('skill-preview');
|
||||
setFeedback(null);
|
||||
} else if (phase !== 'list') {
|
||||
setPhase('list');
|
||||
setSelectedItem(null);
|
||||
setFeedback(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text>Loading inbox…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0 && !feedback) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>Memory Inbox</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>No items in inbox.</Text>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Border + paddingX account for 6 chars of width
|
||||
const contentWidth = terminalWidth - 6;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
{phase === 'list' && (
|
||||
<>
|
||||
<Text bold>
|
||||
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Extracted from past sessions. Select one to review.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelectItem}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={8}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
if (item.value.type === 'header') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.value.type === 'skill') {
|
||||
const skill = item.value.skill;
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{skill.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{skill.description}
|
||||
</Text>
|
||||
{skill.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(skill.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const patch = item.value.patch;
|
||||
const fileNames = patch.entries.map((e) =>
|
||||
getPathBasename(e.targetPath),
|
||||
);
|
||||
const origin = getSkillOriginTag(
|
||||
patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={titleColor} bold>
|
||||
{patch.name}
|
||||
</Text>
|
||||
{origin && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{` [${origin}]`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{fileNames.join(', ')}
|
||||
</Text>
|
||||
{patch.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(patch.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.skill.name}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Review new skill before installing.
|
||||
</Text>
|
||||
|
||||
{selectedItem.skill.content && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
SKILL.md
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff(
|
||||
'SKILL.md',
|
||||
selectedItem.skill.content,
|
||||
)}
|
||||
filename="SKILL.md"
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<SkillPreviewAction>
|
||||
items={skillPreviewItems}
|
||||
onSelect={handleSkillPreviewAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedItem.skill.name}"</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose where to install this skill.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<DestinationChoice>
|
||||
items={destinationItems}
|
||||
onSelect={handleSelectDestination}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.patch.name}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
Review changes before applying.
|
||||
</Text>
|
||||
{(() => {
|
||||
const origin = getSkillOriginTag(
|
||||
selectedItem.patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return origin ? (
|
||||
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{selectedItem.patch.entries.map((entry, index) => (
|
||||
<Box
|
||||
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{entry.targetPath}
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={entry.diffContent}
|
||||
filename={entry.targetPath}
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<PatchAction>
|
||||
items={patchActionItems}
|
||||
onSelect={handleSelectPatchAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** The fraction of the dialog width allocated to the selection (left) pane. */
|
||||
export const SELECTION_PANE_WIDTH_PERCENTAGE = 0.45;
|
||||
|
||||
/** The fraction of the dialog width allocated to the preview (right) pane. */
|
||||
export const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||
|
||||
/**
|
||||
* A safety margin to prevent text from touching the preview pane border.
|
||||
* Note: This is specific to the ThemeDialog layout and is unrelated to
|
||||
* SHELL_WIDTH_FRACTION in AppContainer.
|
||||
*/
|
||||
export const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
|
||||
|
||||
/**
|
||||
* Combined horizontal padding from the dialog and preview pane used
|
||||
* to calculate available width for the code preview.
|
||||
*/
|
||||
export const TOTAL_HORIZONTAL_PADDING = 4;
|
||||
|
||||
/** Padding for the dialog container. */
|
||||
export const DIALOG_PADDING = 2;
|
||||
|
||||
/** Fixed vertical space taken by preview pane elements (title, borders, margins). */
|
||||
export const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
|
||||
|
||||
/** Height of the tab/scope selection hint at the bottom. */
|
||||
export const TAB_TO_SELECT_HEIGHT = 2;
|
||||
@@ -77,6 +77,16 @@ function generateThemeItem(
|
||||
};
|
||||
}
|
||||
|
||||
import {
|
||||
DIALOG_PADDING,
|
||||
PREVIEW_PANE_FIXED_VERTICAL_SPACE,
|
||||
PREVIEW_PANE_WIDTH_PERCENTAGE,
|
||||
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
|
||||
SELECTION_PANE_WIDTH_PERCENTAGE,
|
||||
TAB_TO_SELECT_HEIGHT,
|
||||
TOTAL_HORIZONTAL_PADDING,
|
||||
} from './ThemeDialog.constants.js';
|
||||
|
||||
export function ThemeDialog({
|
||||
onSelect,
|
||||
onCancel,
|
||||
@@ -190,14 +200,6 @@ export function ThemeDialog({
|
||||
settings,
|
||||
);
|
||||
|
||||
// Constants for calculating preview pane layout.
|
||||
// These values are based on the JSX structure below.
|
||||
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||
// A safety margin to prevent text from touching the border.
|
||||
// This is a complete hack unrelated to the 0.9 used in App.tsx
|
||||
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
|
||||
// Combined horizontal padding from the dialog and preview pane.
|
||||
const TOTAL_HORIZONTAL_PADDING = 4;
|
||||
const colorizeCodeWidth = Math.max(
|
||||
Math.floor(
|
||||
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
|
||||
@@ -207,9 +209,7 @@ export function ThemeDialog({
|
||||
1,
|
||||
);
|
||||
|
||||
const DIALOG_PADDING = 2;
|
||||
const selectThemeHeight = themeItems.length + 1;
|
||||
const TAB_TO_SELECT_HEIGHT = 2;
|
||||
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
availableTerminalHeight -= 2; // Top and bottom borders.
|
||||
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
|
||||
@@ -224,10 +224,6 @@ export function ThemeDialog({
|
||||
totalLeftHandSideHeight -= DIALOG_PADDING;
|
||||
}
|
||||
|
||||
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||
// Includes "Preview" title, borders, and margin between blocks.
|
||||
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
|
||||
|
||||
// The right column doesn't need to ever be shorter than the left column.
|
||||
availableTerminalHeight = Math.max(
|
||||
availableTerminalHeight,
|
||||
@@ -252,6 +248,9 @@ export function ThemeDialog({
|
||||
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
|
||||
DEFAULT_THEME;
|
||||
|
||||
const leftColumnWidth = `${SELECTION_PANE_WIDTH_PERCENTAGE * 100}%`;
|
||||
const rightColumnWidth = `${PREVIEW_PANE_WIDTH_PERCENTAGE * 100}%`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -266,7 +265,7 @@ export function ThemeDialog({
|
||||
{mode === 'theme' ? (
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Box flexDirection="column" width={leftColumnWidth} paddingRight={2}>
|
||||
<Text bold={mode === 'theme'} wrap="truncate">
|
||||
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
@@ -340,7 +339,7 @@ export function ThemeDialog({
|
||||
</Box>
|
||||
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Box flexDirection="column" width={rightColumnWidth} paddingLeft={2}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Preview
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { VoiceModelDialog } from './VoiceModelDialog.js';
|
||||
import { act } from 'react';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
isBinaryAvailable: vi.fn().mockReturnValue(true),
|
||||
WhisperModelManager: vi.fn().mockImplementation(() => ({
|
||||
isModelInstalled: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
downloadModel: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('VoiceModelDialog', () => {
|
||||
it('should display a privacy warning when Gemini Live API (Cloud) is selected', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<VoiceModelDialog onClose={onClose} />,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Gemini Live API (Cloud)');
|
||||
expect(frame).toContain('When using the Gemini Live backend');
|
||||
});
|
||||
|
||||
it('should NOT display a privacy warning when Whisper (Local) is highlighted', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { lastFrame, waitUntilReady, stdin } = await renderWithProviders(
|
||||
<VoiceModelDialog onClose={onClose} />,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify warning is present for default (Gemini Live)
|
||||
expect(lastFrame()).toContain('When using the Gemini Live backend');
|
||||
|
||||
// Arrow Down to highlight Whisper
|
||||
await act(async () => {
|
||||
stdin.write('\u001b[B');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Whisper (Local)');
|
||||
expect(frame).not.toContain('When using the Gemini Live backend');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update settings and close dialog when a backend is selected', async () => {
|
||||
const onClose = vi.fn();
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const { waitUntilReady, stdin } = await renderWithProviders(
|
||||
<VoiceModelDialog onClose={onClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Select Gemini Live (it's already highlighted, just press Enter)
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental.voice.backend',
|
||||
'gemini-live',
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type WhisperModelProgress,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
|
||||
interface VoiceModelDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -68,6 +69,9 @@ export function VoiceModelDialog({
|
||||
const currentWhisperModel =
|
||||
settings.merged.experimental.voice?.whisperModel ?? 'ggml-base.en.bin';
|
||||
|
||||
const [highlightedBackend, setHighlightedBackend] =
|
||||
useState<string>(currentBackend);
|
||||
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape') {
|
||||
@@ -101,6 +105,10 @@ export function VoiceModelDialog({
|
||||
[setSetting, onClose],
|
||||
);
|
||||
|
||||
const handleBackendHighlight = useCallback((value: string) => {
|
||||
setHighlightedBackend(value);
|
||||
}, []);
|
||||
|
||||
const handleWhisperModelSelect = useCallback(
|
||||
async (modelName: string) => {
|
||||
if (modelManager.isModelInstalled(modelName)) {
|
||||
@@ -203,14 +211,22 @@ export function VoiceModelDialog({
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{view === 'backend' ? (
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={backendOptions}
|
||||
onSelect={handleBackendSelect}
|
||||
initialIndex={currentBackend === 'whisper' ? 1 : 0}
|
||||
showNumbers={true}
|
||||
/>
|
||||
<>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={backendOptions}
|
||||
onSelect={handleBackendSelect}
|
||||
onHighlight={handleBackendHighlight}
|
||||
initialIndex={currentBackend === 'whisper' ? 1 : 0}
|
||||
showNumbers={true}
|
||||
/>
|
||||
{highlightedBackend === 'gemini-live' && (
|
||||
<Box marginTop={1}>
|
||||
<WarningMessage text="When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. Enterprise users should verify this aligns with their data privacy and compliance requirements." />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={whisperOptions}
|
||||
|
||||
@@ -12,7 +12,10 @@ import { useGitBranchName } from './useGitBranchName.js';
|
||||
import { fs, vol } from 'memfs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path'; // For mocking fs
|
||||
import { spawnAsync as mockSpawnAsync } from '@google/gemini-cli-core';
|
||||
import {
|
||||
spawnAsync as mockSpawnAsync,
|
||||
getAbsoluteGitDir as mockGetAbsoluteGitDir,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock @google/gemini-cli-core
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
@@ -22,6 +25,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
return {
|
||||
...original,
|
||||
spawnAsync: vi.fn(),
|
||||
getAbsoluteGitDir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -40,19 +44,21 @@ vi.mock('node:fs/promises', async () => {
|
||||
});
|
||||
|
||||
const CWD = '/test/project';
|
||||
const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
|
||||
const GIT_DIR = path.join(CWD, '.git');
|
||||
const GIT_HEAD_PATH = path.join(GIT_DIR, 'HEAD');
|
||||
|
||||
describe('useGitBranchName', () => {
|
||||
let deferredSpawn: Array<{
|
||||
resolve: (val: { stdout: string; stderr: string }) => void;
|
||||
resolve: (val: { stdout: string; stderr: string; code: number }) => void;
|
||||
reject: (err: Error) => void;
|
||||
args: string[];
|
||||
}> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vol.reset(); // Reset in-memory filesystem
|
||||
vol.fromJSON({
|
||||
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
|
||||
[GIT_HEAD_PATH]: 'ref: refs/heads/main',
|
||||
});
|
||||
|
||||
deferredSpawn = [];
|
||||
@@ -62,9 +68,11 @@ describe('useGitBranchName', () => {
|
||||
deferredSpawn.push({ resolve, reject, args });
|
||||
}),
|
||||
);
|
||||
vi.mocked(mockGetAbsoluteGitDir).mockResolvedValue(GIT_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -86,16 +94,35 @@ describe('useGitBranchName', () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to resolve pending spawns for a hook render.
|
||||
*/
|
||||
const resolveInitialSpawns = async (branch: string = 'main') => {
|
||||
await act(async () => {
|
||||
let resolvedAny = true;
|
||||
while (resolvedAny || deferredSpawn.length > 0) {
|
||||
resolvedAny = false;
|
||||
while (deferredSpawn.length > 0) {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
if (spawn.args.includes('--abbrev-ref')) {
|
||||
spawn.resolve({ stdout: `${branch}\n`, stderr: '', code: 0 });
|
||||
resolvedAny = true;
|
||||
} else if (spawn.args.includes('--short')) {
|
||||
spawn.resolve({ stdout: `${branch}\n`, stderr: '', code: 0 });
|
||||
resolvedAny = true;
|
||||
}
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
it('should return branch name', async () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
await resolveInitialSpawns('main');
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
});
|
||||
@@ -104,9 +131,13 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.reject(new Error('Git error'));
|
||||
const abbrevSpawn = deferredSpawn.find((s) =>
|
||||
s.args.includes('--abbrev-ref'),
|
||||
);
|
||||
if (abbrevSpawn) {
|
||||
abbrevSpawn.reject(new Error('Git error'));
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
@@ -116,16 +147,22 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
const abbrevSpawn = deferredSpawn.find((s) =>
|
||||
s.args.includes('--abbrev-ref'),
|
||||
)!;
|
||||
abbrevSpawn.resolve({ stdout: 'HEAD\n', stderr: '', code: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// It should now call spawnAsync again for the short hash
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--short');
|
||||
spawn.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
|
||||
const shortSpawn = deferredSpawn.find((s) => s.args.includes('--short'));
|
||||
if (shortSpawn) {
|
||||
shortSpawn.resolve({ stdout: 'a1b2c3d\n', stderr: '', code: 0 });
|
||||
} else {
|
||||
throw new Error('Short spawn not found');
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('a1b2c3d');
|
||||
@@ -135,15 +172,21 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
const abbrevSpawn = deferredSpawn.find((s) =>
|
||||
s.args.includes('--abbrev-ref'),
|
||||
)!;
|
||||
abbrevSpawn.resolve({ stdout: 'HEAD\n', stderr: '', code: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--short');
|
||||
spawn.reject(new Error('Git error'));
|
||||
const shortSpawn = deferredSpawn.find((s) => s.args.includes('--short'));
|
||||
if (shortSpawn) {
|
||||
shortSpawn.reject(new Error('Git error'));
|
||||
} else {
|
||||
throw new Error('Short spawn not found');
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
@@ -151,64 +194,94 @@ describe('useGitBranchName', () => {
|
||||
|
||||
it('should update branch name when .git/HEAD changes', async () => {
|
||||
vi.spyOn(fsPromises, 'access').mockResolvedValue(undefined);
|
||||
const watchSpy = vi.spyOn(fs, 'watch');
|
||||
let watchCallback:
|
||||
| ((eventType: string, filename: string | null) => void)
|
||||
| undefined;
|
||||
const watchSpy = vi.spyOn(fs, 'watch').mockImplementation(((
|
||||
_path: string,
|
||||
callback: (eventType: string, filename: string | null) => void,
|
||||
) => {
|
||||
watchCallback = callback;
|
||||
return { close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
await resolveInitialSpawns('main');
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
|
||||
// Wait for watcher to be set up
|
||||
await waitFor(() => {
|
||||
expect(watchSpy).toHaveBeenCalled();
|
||||
expect(watchSpy).toHaveBeenCalledWith(GIT_DIR, expect.any(Function));
|
||||
});
|
||||
|
||||
// Simulate file change event
|
||||
// Simulate file change event for HEAD
|
||||
await act(async () => {
|
||||
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher
|
||||
if (watchCallback) {
|
||||
watchCallback('change', 'HEAD');
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(150); // triggers debounce
|
||||
});
|
||||
|
||||
// Resolving the new branch name fetch
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'develop\n', stderr: '' });
|
||||
// Find the specific abbrev-ref spawn for this update
|
||||
const spawn = deferredSpawn.find((s) => s.args.includes('--abbrev-ref'))!;
|
||||
// Remove it from the array so subsequent lookups don't find the same one
|
||||
deferredSpawn.splice(deferredSpawn.indexOf(spawn), 1);
|
||||
spawn.resolve({ stdout: 'develop\n', stderr: '', code: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('develop');
|
||||
|
||||
// Simulate file change event with null filename (platform compatibility)
|
||||
await act(async () => {
|
||||
if (watchCallback) {
|
||||
watchCallback('change', null);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
|
||||
// Resolving the new branch name fetch
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.find((s) => s.args.includes('--abbrev-ref'))!;
|
||||
deferredSpawn.splice(deferredSpawn.indexOf(spawn), 1);
|
||||
spawn.resolve({ stdout: 'feature-x\n', stderr: '', code: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('feature-x');
|
||||
});
|
||||
|
||||
it('should handle watcher setup error silently', async () => {
|
||||
// Remove .git/logs/HEAD to cause an error in fs.watch setup
|
||||
vol.unlinkSync(GIT_LOGS_HEAD_PATH);
|
||||
// Cause an error in absolute git dir setup
|
||||
vi.mocked(mockGetAbsoluteGitDir).mockRejectedValueOnce(
|
||||
new Error('Git error'),
|
||||
);
|
||||
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '', code: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
|
||||
// This write would trigger the watcher if it was set up
|
||||
// We need to create the file again for writeFileSync to not throw
|
||||
vol.fromJSON({
|
||||
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/develop',
|
||||
});
|
||||
|
||||
// Trigger a mock write that would normally be watched
|
||||
await act(async () => {
|
||||
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
|
||||
fs.writeFileSync(GIT_HEAD_PATH, 'ref: refs/heads/develop');
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// spawnAsync should NOT have been called again for updating
|
||||
expect(deferredSpawn.length).toBe(0);
|
||||
expect(
|
||||
deferredSpawn.filter((s) => s.args.includes('--abbrev-ref')).length,
|
||||
).toBe(0);
|
||||
expect(result.current).toBe('main');
|
||||
});
|
||||
|
||||
@@ -221,18 +294,11 @@ describe('useGitBranchName', () => {
|
||||
|
||||
const { unmount } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
await resolveInitialSpawns('main');
|
||||
|
||||
// Wait for watcher to be set up BEFORE unmounting
|
||||
await waitFor(() => {
|
||||
expect(watchMock).toHaveBeenCalledWith(
|
||||
GIT_LOGS_HEAD_PATH,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(watchMock).toHaveBeenCalledWith(GIT_DIR, expect.any(Function));
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { spawnAsync } from '@google/gemini-cli-core';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { spawnAsync, getAbsoluteGitDir } from '@google/gemini-cli-core';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export function useGitBranchName(cwd: string): string | undefined {
|
||||
const [branchName, setBranchName] = useState<string | undefined>(undefined);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchBranchName = useCallback(async () => {
|
||||
try {
|
||||
@@ -37,26 +37,41 @@ export function useGitBranchName(cwd: string): string | undefined {
|
||||
}, [cwd, setBranchName]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
fetchBranchName(); // Initial fetch
|
||||
void fetchBranchName(); // Initial fetch
|
||||
|
||||
const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD');
|
||||
let watcher: fs.FSWatcher | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const setupWatcher = async () => {
|
||||
try {
|
||||
// Check if .git/logs/HEAD exists, as it might not in a new repo or orphaned head
|
||||
await fsPromises.access(gitLogsHeadPath, fs.constants.F_OK);
|
||||
const gitDir = await getAbsoluteGitDir(cwd);
|
||||
if (!gitDir) return;
|
||||
|
||||
// Ensure we can access the git dir
|
||||
await fsPromises.access(gitDir, fs.constants.F_OK);
|
||||
if (cancelled) return;
|
||||
watcher = fs.watch(gitLogsHeadPath, (eventType: string) => {
|
||||
// Changes to .git/logs/HEAD (appends) indicate HEAD has likely changed
|
||||
if (eventType === 'change' || eventType === 'rename') {
|
||||
// Handle rename just in case
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
fetchBranchName();
|
||||
}
|
||||
});
|
||||
|
||||
const w = fs.watch(
|
||||
gitDir,
|
||||
(eventType: string, filename: string | null) => {
|
||||
// Changes to HEAD indicate branch checkout or detached commit.
|
||||
// On some platforms filename may be null, so we refresh in that case too.
|
||||
if (!filename || filename === 'HEAD') {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void fetchBranchName();
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
w.close();
|
||||
} else {
|
||||
watcher = w;
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore watcher errors (e.g. permissions or file not existing),
|
||||
// similar to how exec errors are handled.
|
||||
@@ -64,11 +79,13 @@ export function useGitBranchName(cwd: string): string | undefined {
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
setupWatcher();
|
||||
void setupWatcher();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
watcher?.close();
|
||||
};
|
||||
}, [cwd, fetchBranchName]);
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertLatexToUnicode } from './latexToUnicode.js';
|
||||
|
||||
describe('convertLatexToUnicode', () => {
|
||||
describe('fast path', () => {
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(convertLatexToUnicode('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns text without backslash or dollar unchanged', () => {
|
||||
const input = 'hello world 123';
|
||||
expect(convertLatexToUnicode(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('short-circuits plain ASCII identically', () => {
|
||||
const input = 'The quick brown fox jumps over the lazy dog.';
|
||||
expect(convertLatexToUnicode(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #25656 examples', () => {
|
||||
it('converts the set-of-processes example', () => {
|
||||
const input = 'A set of processes $\\{P_0, P_1, \\dots, P_n\\}$ exists';
|
||||
expect(convertLatexToUnicode(input)).toBe(
|
||||
'A set of processes {P₀, P₁, …, Pₙ} exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts the deadlock arrow example', () => {
|
||||
const input = 'If the graph contains no cycles $\\to$ No Deadlock.';
|
||||
expect(convertLatexToUnicode(input)).toBe(
|
||||
'If the graph contains no cycles → No Deadlock.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('math delimiters', () => {
|
||||
it('strips $...$ when the content contains LaTeX markers', () => {
|
||||
expect(convertLatexToUnicode('see $\\alpha$ here')).toBe('see α here');
|
||||
});
|
||||
|
||||
it('strips $...$ around single variables', () => {
|
||||
expect(convertLatexToUnicode('let $x$ be a value')).toBe(
|
||||
'let x be a value',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips $$...$$ display math', () => {
|
||||
expect(convertLatexToUnicode('$$\\alpha + \\beta$$')).toBe('α + β');
|
||||
});
|
||||
|
||||
it('leaves currency $5.99 alone', () => {
|
||||
expect(convertLatexToUnicode('It costs $5.99 total')).toBe(
|
||||
'It costs $5.99 total',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves two dollar amounts alone', () => {
|
||||
// The regex matches `$5 to $` as a pair, but the inner content is
|
||||
// neither mathy nor purely variables, so it is left intact.
|
||||
expect(convertLatexToUnicode('prices range $5 to $10')).toBe(
|
||||
'prices range $5 to $10',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves shell-style $ interpolation alone', () => {
|
||||
expect(convertLatexToUnicode('echo $USER $HOME')).toBe(
|
||||
'echo $USER $HOME',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not strip dollars across newlines', () => {
|
||||
expect(convertLatexToUnicode('price $5\nfee $3')).toBe(
|
||||
'price $5\nfee $3',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('greek letters', () => {
|
||||
it('converts lowercase greek', () => {
|
||||
expect(convertLatexToUnicode('\\alpha \\beta \\gamma')).toBe('α β γ');
|
||||
});
|
||||
|
||||
it('converts uppercase greek', () => {
|
||||
expect(convertLatexToUnicode('\\Omega \\Delta')).toBe('Ω Δ');
|
||||
});
|
||||
|
||||
it('does not mangle a prefix match', () => {
|
||||
// `\alphabet` is not a known command — must stay intact.
|
||||
expect(convertLatexToUnicode('\\alphabet')).toBe('\\alphabet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('named commands', () => {
|
||||
it('converts arrows', () => {
|
||||
expect(convertLatexToUnicode('\\to \\rightarrow \\Rightarrow')).toBe(
|
||||
'→ → ⇒',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts relations', () => {
|
||||
expect(convertLatexToUnicode('\\leq \\geq \\neq \\approx')).toBe(
|
||||
'≤ ≥ ≠ ≈',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts set theory', () => {
|
||||
expect(convertLatexToUnicode('\\in \\notin \\cup \\cap')).toBe('∈ ∉ ∪ ∩');
|
||||
});
|
||||
|
||||
it('converts logic', () => {
|
||||
expect(convertLatexToUnicode('\\forall x \\exists y')).toBe('∀ x ∃ y');
|
||||
});
|
||||
|
||||
it('converts large operators', () => {
|
||||
expect(convertLatexToUnicode('\\sum \\prod \\int')).toBe('∑ ∏ ∫');
|
||||
});
|
||||
|
||||
it('converts ellipses', () => {
|
||||
expect(convertLatexToUnicode('a, b, \\dots, z')).toBe('a, b, …, z');
|
||||
});
|
||||
|
||||
it('converts infty', () => {
|
||||
expect(convertLatexToUnicode('\\infty')).toBe('∞');
|
||||
});
|
||||
|
||||
it('leaves unknown commands untouched', () => {
|
||||
expect(convertLatexToUnicode('\\thisIsNotReal')).toBe('\\thisIsNotReal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped specials', () => {
|
||||
it('unescapes braces and underscore', () => {
|
||||
expect(convertLatexToUnicode('\\{ \\} \\_')).toBe('{ } _');
|
||||
});
|
||||
|
||||
it('unescapes percent, ampersand, hash, dollar, pipe', () => {
|
||||
expect(convertLatexToUnicode('\\% \\& \\# \\$ \\|')).toBe('% & # $ |');
|
||||
});
|
||||
|
||||
it('unescapes backslash-space as a regular space', () => {
|
||||
expect(convertLatexToUnicode('word\\ boundary')).toBe('word boundary');
|
||||
});
|
||||
|
||||
it('converts \\\\ to a newline inside math mode', () => {
|
||||
// `\\` is a LaTeX line break in math/tabular contexts. Only convert
|
||||
// inside `$...$` — outside math this would mangle Windows UNC paths
|
||||
// (`\\server\share`) and escaped backslashes in code-like prose.
|
||||
expect(convertLatexToUnicode('$a\\\\b$')).toBe('a\nb');
|
||||
});
|
||||
|
||||
it('leaves \\\\ alone outside math mode', () => {
|
||||
expect(convertLatexToUnicode('line1\\\\line2')).toBe('line1\\\\line2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('text formatting', () => {
|
||||
it('wraps textbf in markdown bold', () => {
|
||||
expect(convertLatexToUnicode('\\textbf{hello}')).toBe('**hello**');
|
||||
});
|
||||
|
||||
it('wraps textit in markdown italic', () => {
|
||||
expect(convertLatexToUnicode('\\textit{hello}')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('strips \\text wrapper', () => {
|
||||
expect(convertLatexToUnicode('\\text{plain}')).toBe('plain');
|
||||
});
|
||||
|
||||
it('strips \\mathrm', () => {
|
||||
expect(convertLatexToUnicode('\\mathrm{foo}')).toBe('foo');
|
||||
});
|
||||
|
||||
it('handles \\emph as italic', () => {
|
||||
expect(convertLatexToUnicode('\\emph{emphasized}')).toBe('*emphasized*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fractions and roots', () => {
|
||||
it('converts \\frac', () => {
|
||||
expect(convertLatexToUnicode('\\frac{a}{b}')).toBe('(a)/(b)');
|
||||
});
|
||||
|
||||
it('converts \\sqrt', () => {
|
||||
expect(convertLatexToUnicode('\\sqrt{x}')).toBe('√(x)');
|
||||
});
|
||||
|
||||
it('converts \\sqrt with index', () => {
|
||||
expect(convertLatexToUnicode('\\sqrt[3]{x}')).toBe('3√(x)');
|
||||
});
|
||||
|
||||
it('converts \\frac combined with greek', () => {
|
||||
expect(convertLatexToUnicode('\\frac{\\alpha}{\\beta}')).toBe('(α)/(β)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscripts and superscripts', () => {
|
||||
// Sub/superscripts are only applied inside math delimiters to avoid
|
||||
// mangling identifiers like `file_name` and `foo_bar` in regular prose.
|
||||
it('converts digit subscripts inside math', () => {
|
||||
expect(convertLatexToUnicode('$x_0 + x_1 + x_2$')).toBe('x₀ + x₁ + x₂');
|
||||
});
|
||||
|
||||
it('converts digit superscripts inside math', () => {
|
||||
expect(convertLatexToUnicode('$E = mc^2$')).toBe('E = mc²');
|
||||
});
|
||||
|
||||
it('converts letter subscripts where available', () => {
|
||||
expect(convertLatexToUnicode('$P_n$ and $x_i$')).toBe('Pₙ and xᵢ');
|
||||
});
|
||||
|
||||
it('converts braced digit subscripts', () => {
|
||||
expect(convertLatexToUnicode('$x_{12}$')).toBe('x₁₂');
|
||||
});
|
||||
|
||||
it('leaves subscripts with no unicode mapping alone', () => {
|
||||
// `q` has no subscript glyph in Unicode — leave the whole operand
|
||||
// untouched to avoid inconsistent-looking output.
|
||||
expect(convertLatexToUnicode('$x_{abq}$')).toBe('x_{abq}');
|
||||
});
|
||||
|
||||
it('does not subscript identifiers in prose', () => {
|
||||
// Outside math delimiters, `_` is left alone entirely so that
|
||||
// snake_case identifiers and file paths render correctly. This is a
|
||||
// deliberate trade-off against model output that emits subscripts
|
||||
// unwrapped.
|
||||
expect(convertLatexToUnicode('the file_name variable')).toBe(
|
||||
'the file_name variable',
|
||||
);
|
||||
expect(convertLatexToUnicode('_private')).toBe('_private');
|
||||
});
|
||||
|
||||
it('does not superscript when character is unmapped in sup', () => {
|
||||
// `^Q` — Q has no superscript. The regex only matches when the char is
|
||||
// in the map; leave as-is even inside math.
|
||||
expect(convertLatexToUnicode('$x^Q$')).toBe('x^Q');
|
||||
});
|
||||
|
||||
it('leaves bare x_0 alone outside math', () => {
|
||||
// Deliberate: we cannot tell `P_0` (subscript) from `my_0` (identifier)
|
||||
// in arbitrary prose, so prefer to preserve identifiers.
|
||||
expect(convertLatexToUnicode('x_0 is fine')).toBe('x_0 is fine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection of non-LaTeX content', () => {
|
||||
it('leaves Windows paths alone', () => {
|
||||
expect(convertLatexToUnicode('C:\\Users\\foo\\bar')).toBe(
|
||||
'C:\\Users\\foo\\bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves Windows UNC paths alone (no line-break rewrite in prose)', () => {
|
||||
// `\\server\share\file` must NOT be rewritten to a newline. Line-break
|
||||
// conversion is restricted to math mode. See PR #25802.
|
||||
expect(convertLatexToUnicode('\\\\server\\share\\file')).toBe(
|
||||
'\\\\server\\share\\file',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves regex backslash escapes alone', () => {
|
||||
expect(convertLatexToUnicode('\\d+\\w*')).toBe('\\d+\\w*');
|
||||
});
|
||||
|
||||
it('leaves $ in code-like prose alone', () => {
|
||||
expect(convertLatexToUnicode('run $(command)$ to see output')).toBe(
|
||||
'run $(command)$ to see output',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined scenarios', () => {
|
||||
it('handles complex math in prose', () => {
|
||||
const input =
|
||||
'The complexity is $O(n \\log n)$ for sorting $n$ elements.';
|
||||
expect(convertLatexToUnicode(input)).toBe(
|
||||
'The complexity is O(n log n) for sorting n elements.',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple constructs in one line', () => {
|
||||
const input = 'Let $\\alpha \\in \\mathbb{R}$ and $\\beta \\geq 0$.';
|
||||
expect(convertLatexToUnicode(input)).toBe('Let α ∈ R and β ≥ 0.');
|
||||
});
|
||||
|
||||
it('preserves surrounding text exactly', () => {
|
||||
const input = 'Before $\\to$ after.';
|
||||
expect(convertLatexToUnicode(input)).toBe('Before → after.');
|
||||
});
|
||||
|
||||
it('idempotency — running twice yields the same result', () => {
|
||||
const input = '$\\{P_0, \\dots, P_n\\}$';
|
||||
const once = convertLatexToUnicode(input);
|
||||
const twice = convertLatexToUnicode(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts common LaTeX-style syntax in model output into terminal-friendly
|
||||
* Unicode (and lightweight markdown where appropriate).
|
||||
*
|
||||
* Terminals cannot natively render LaTeX, but model responses — especially for
|
||||
* math, CS, and algorithms — frequently include constructs like `$\{P_0,
|
||||
* \dots, P_n\}$` or `$\to$`. Left as-is, the raw backslash commands show up
|
||||
* verbatim and make the output look broken.
|
||||
*
|
||||
* This function is a conservative, lossy post-processor that handles the
|
||||
* common cases and leaves anything it does not recognise untouched, so that
|
||||
* legitimate backslash content (e.g. Windows paths, regex examples) is not
|
||||
* mangled.
|
||||
*
|
||||
* See issue #25656.
|
||||
*/
|
||||
|
||||
// Greek letters, lower and upper case, plus the common "var" variants.
|
||||
const GREEK_LETTERS: Readonly<Record<string, string>> = Object.freeze({
|
||||
alpha: 'α',
|
||||
beta: 'β',
|
||||
gamma: 'γ',
|
||||
delta: 'δ',
|
||||
epsilon: 'ε',
|
||||
zeta: 'ζ',
|
||||
eta: 'η',
|
||||
theta: 'θ',
|
||||
iota: 'ι',
|
||||
kappa: 'κ',
|
||||
lambda: 'λ',
|
||||
mu: 'μ',
|
||||
nu: 'ν',
|
||||
xi: 'ξ',
|
||||
omicron: 'ο',
|
||||
pi: 'π',
|
||||
rho: 'ρ',
|
||||
sigma: 'σ',
|
||||
tau: 'τ',
|
||||
upsilon: 'υ',
|
||||
phi: 'φ',
|
||||
chi: 'χ',
|
||||
psi: 'ψ',
|
||||
omega: 'ω',
|
||||
Alpha: 'Α',
|
||||
Beta: 'Β',
|
||||
Gamma: 'Γ',
|
||||
Delta: 'Δ',
|
||||
Epsilon: 'Ε',
|
||||
Zeta: 'Ζ',
|
||||
Eta: 'Η',
|
||||
Theta: 'Θ',
|
||||
Iota: 'Ι',
|
||||
Kappa: 'Κ',
|
||||
Lambda: 'Λ',
|
||||
Mu: 'Μ',
|
||||
Nu: 'Ν',
|
||||
Xi: 'Ξ',
|
||||
Omicron: 'Ο',
|
||||
Pi: 'Π',
|
||||
Rho: 'Ρ',
|
||||
Sigma: 'Σ',
|
||||
Tau: 'Τ',
|
||||
Upsilon: 'Υ',
|
||||
Phi: 'Φ',
|
||||
Chi: 'Χ',
|
||||
Psi: 'Ψ',
|
||||
Omega: 'Ω',
|
||||
varepsilon: 'ε',
|
||||
vartheta: 'ϑ',
|
||||
varphi: 'φ',
|
||||
varrho: 'ϱ',
|
||||
varsigma: 'ς',
|
||||
varpi: 'ϖ',
|
||||
});
|
||||
|
||||
// Named LaTeX commands → Unicode. Covers arrows, relations, set theory,
|
||||
// logic, large operators, and a handful of common decorations. Anything not
|
||||
// listed here is deliberately left untouched.
|
||||
const LATEX_COMMANDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
// Arrows
|
||||
to: '→',
|
||||
rightarrow: '→',
|
||||
Rightarrow: '⇒',
|
||||
leftarrow: '←',
|
||||
Leftarrow: '⇐',
|
||||
leftrightarrow: '↔',
|
||||
Leftrightarrow: '⇔',
|
||||
mapsto: '↦',
|
||||
longrightarrow: '⟶',
|
||||
longleftarrow: '⟵',
|
||||
longleftrightarrow: '⟷',
|
||||
uparrow: '↑',
|
||||
downarrow: '↓',
|
||||
Uparrow: '⇑',
|
||||
Downarrow: '⇓',
|
||||
hookrightarrow: '↪',
|
||||
hookleftarrow: '↩',
|
||||
|
||||
// Ellipses
|
||||
dots: '…',
|
||||
ldots: '…',
|
||||
cdots: '⋯',
|
||||
vdots: '⋮',
|
||||
ddots: '⋱',
|
||||
|
||||
// Arithmetic / comparison
|
||||
times: '×',
|
||||
cdot: '·',
|
||||
div: '÷',
|
||||
pm: '±',
|
||||
mp: '∓',
|
||||
ast: '∗',
|
||||
leq: '≤',
|
||||
le: '≤',
|
||||
geq: '≥',
|
||||
ge: '≥',
|
||||
neq: '≠',
|
||||
ne: '≠',
|
||||
ll: '≪',
|
||||
gg: '≫',
|
||||
approx: '≈',
|
||||
equiv: '≡',
|
||||
sim: '∼',
|
||||
simeq: '≃',
|
||||
cong: '≅',
|
||||
propto: '∝',
|
||||
|
||||
// Set theory
|
||||
in: '∈',
|
||||
notin: '∉',
|
||||
ni: '∋',
|
||||
subset: '⊂',
|
||||
supset: '⊃',
|
||||
subseteq: '⊆',
|
||||
supseteq: '⊇',
|
||||
cup: '∪',
|
||||
cap: '∩',
|
||||
setminus: '∖',
|
||||
emptyset: '∅',
|
||||
varnothing: '∅',
|
||||
|
||||
// Logic
|
||||
forall: '∀',
|
||||
exists: '∃',
|
||||
nexists: '∄',
|
||||
neg: '¬',
|
||||
lnot: '¬',
|
||||
land: '∧',
|
||||
wedge: '∧',
|
||||
lor: '∨',
|
||||
vee: '∨',
|
||||
oplus: '⊕',
|
||||
otimes: '⊗',
|
||||
implies: '⟹',
|
||||
iff: '⟺',
|
||||
|
||||
// Large operators
|
||||
sum: '∑',
|
||||
prod: '∏',
|
||||
coprod: '∐',
|
||||
int: '∫',
|
||||
iint: '∬',
|
||||
iiint: '∭',
|
||||
oint: '∮',
|
||||
|
||||
// Calculus
|
||||
partial: '∂',
|
||||
nabla: '∇',
|
||||
infty: '∞',
|
||||
|
||||
// Misc letters / constants
|
||||
ell: 'ℓ',
|
||||
hbar: 'ℏ',
|
||||
Re: 'ℜ',
|
||||
Im: 'ℑ',
|
||||
aleph: 'ℵ',
|
||||
beth: 'ℶ',
|
||||
|
||||
// Brackets / delimiters
|
||||
lbrace: '{',
|
||||
rbrace: '}',
|
||||
lbrack: '[',
|
||||
rbrack: ']',
|
||||
langle: '⟨',
|
||||
rangle: '⟩',
|
||||
lceil: '⌈',
|
||||
rceil: '⌉',
|
||||
lfloor: '⌊',
|
||||
rfloor: '⌋',
|
||||
|
||||
// Geometry / misc
|
||||
perp: '⊥',
|
||||
parallel: '∥',
|
||||
angle: '∠',
|
||||
triangle: '△',
|
||||
square: '□',
|
||||
circ: '∘',
|
||||
bullet: '•',
|
||||
star: '⋆',
|
||||
prime: '′',
|
||||
dag: '†',
|
||||
ddag: '‡',
|
||||
therefore: '∴',
|
||||
because: '∵',
|
||||
top: '⊤',
|
||||
bot: '⊥',
|
||||
|
||||
// Operator names (`\log`, `\sin`, …) render in LaTeX as upright text. In a
|
||||
// terminal the closest equivalent is the lowercase word itself.
|
||||
log: 'log',
|
||||
ln: 'ln',
|
||||
lg: 'lg',
|
||||
exp: 'exp',
|
||||
sin: 'sin',
|
||||
cos: 'cos',
|
||||
tan: 'tan',
|
||||
cot: 'cot',
|
||||
sec: 'sec',
|
||||
csc: 'csc',
|
||||
arcsin: 'arcsin',
|
||||
arccos: 'arccos',
|
||||
arctan: 'arctan',
|
||||
sinh: 'sinh',
|
||||
cosh: 'cosh',
|
||||
tanh: 'tanh',
|
||||
max: 'max',
|
||||
min: 'min',
|
||||
sup: 'sup',
|
||||
inf: 'inf',
|
||||
lim: 'lim',
|
||||
limsup: 'lim sup',
|
||||
liminf: 'lim inf',
|
||||
arg: 'arg',
|
||||
det: 'det',
|
||||
dim: 'dim',
|
||||
ker: 'ker',
|
||||
gcd: 'gcd',
|
||||
deg: 'deg',
|
||||
hom: 'hom',
|
||||
mod: 'mod',
|
||||
bmod: 'mod',
|
||||
pmod: 'mod',
|
||||
|
||||
// Whitespace commands — render as visible space so layout is roughly right.
|
||||
quad: ' ',
|
||||
qquad: ' ',
|
||||
// These are all "thin-space" style commands in LaTeX; render as a single
|
||||
// space so the surrounding tokens don't jam together.
|
||||
',': ' ',
|
||||
';': ' ',
|
||||
':': ' ',
|
||||
'!': '',
|
||||
});
|
||||
|
||||
// Unicode subscript mappings (digits, operators, and the common letters that
|
||||
// have full-height subscript glyphs in Unicode).
|
||||
const SUBSCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
'0': '₀',
|
||||
'1': '₁',
|
||||
'2': '₂',
|
||||
'3': '₃',
|
||||
'4': '₄',
|
||||
'5': '₅',
|
||||
'6': '₆',
|
||||
'7': '₇',
|
||||
'8': '₈',
|
||||
'9': '₉',
|
||||
'+': '₊',
|
||||
'-': '₋',
|
||||
'=': '₌',
|
||||
'(': '₍',
|
||||
')': '₎',
|
||||
a: 'ₐ',
|
||||
e: 'ₑ',
|
||||
h: 'ₕ',
|
||||
i: 'ᵢ',
|
||||
j: 'ⱼ',
|
||||
k: 'ₖ',
|
||||
l: 'ₗ',
|
||||
m: 'ₘ',
|
||||
n: 'ₙ',
|
||||
o: 'ₒ',
|
||||
p: 'ₚ',
|
||||
r: 'ᵣ',
|
||||
s: 'ₛ',
|
||||
t: 'ₜ',
|
||||
u: 'ᵤ',
|
||||
v: 'ᵥ',
|
||||
x: 'ₓ',
|
||||
});
|
||||
|
||||
// Unicode superscript mappings. A superset of subscripts — most letters have
|
||||
// superscript glyphs.
|
||||
const SUPERSCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
'0': '⁰',
|
||||
'1': '¹',
|
||||
'2': '²',
|
||||
'3': '³',
|
||||
'4': '⁴',
|
||||
'5': '⁵',
|
||||
'6': '⁶',
|
||||
'7': '⁷',
|
||||
'8': '⁸',
|
||||
'9': '⁹',
|
||||
'+': '⁺',
|
||||
'-': '⁻',
|
||||
'=': '⁼',
|
||||
'(': '⁽',
|
||||
')': '⁾',
|
||||
a: 'ᵃ',
|
||||
b: 'ᵇ',
|
||||
c: 'ᶜ',
|
||||
d: 'ᵈ',
|
||||
e: 'ᵉ',
|
||||
f: 'ᶠ',
|
||||
g: 'ᵍ',
|
||||
h: 'ʰ',
|
||||
i: 'ⁱ',
|
||||
j: 'ʲ',
|
||||
k: 'ᵏ',
|
||||
l: 'ˡ',
|
||||
m: 'ᵐ',
|
||||
n: 'ⁿ',
|
||||
o: 'ᵒ',
|
||||
p: 'ᵖ',
|
||||
r: 'ʳ',
|
||||
s: 'ˢ',
|
||||
t: 'ᵗ',
|
||||
u: 'ᵘ',
|
||||
v: 'ᵛ',
|
||||
w: 'ʷ',
|
||||
x: 'ˣ',
|
||||
y: 'ʸ',
|
||||
z: 'ᶻ',
|
||||
});
|
||||
|
||||
/**
|
||||
* Strips `$...$` and `$$...$$` math delimiters when the inner content looks
|
||||
* like math, applying the full set of math-mode conversions (including
|
||||
* sub/superscripts) to the inner text. The goal is to handle model output
|
||||
* without eating dollar signs that appear in ordinary prose (prices,
|
||||
* shell examples, etc.).
|
||||
*
|
||||
* A pair of `$...$` is treated as math when the inner text either:
|
||||
* - contains a LaTeX marker (`\command`, `_`, `^`), or
|
||||
* - is a single letter, possibly with whitespace padding (e.g. `$x$`,
|
||||
* `$ n $`). Shell-style variables like `$USER` are LEFT intact because
|
||||
* multi-letter all-caps sequences look much more like shell vars than
|
||||
* math in practice.
|
||||
*
|
||||
* A currency expression like `$5.99` (single `$`) never matches the pair
|
||||
* regex. `From $5 to $10` matches `$5 to $` as a pair but the inner text is
|
||||
* neither mathy nor a single variable, so it is left intact.
|
||||
*/
|
||||
function stripMathDelimiters(text: string): string {
|
||||
// Display math first, greedy-safe with non-dollar inner class.
|
||||
let out = text.replace(/\$\$([^$]+)\$\$/g, (_, inner: string) =>
|
||||
applyMathModeConversions(inner),
|
||||
);
|
||||
|
||||
// Inline math: lazy, single-line to avoid eating across paragraphs.
|
||||
out = out.replace(/\$([^$\n]+?)\$/g, (match, inner: string) => {
|
||||
const hasLatexMarkers = /\\[A-Za-z]|[\\_^]/.test(inner);
|
||||
const isSingleVariable = /^\s*[A-Za-z]\s*$/.test(inner);
|
||||
if (hasLatexMarkers || isSingleVariable) {
|
||||
return applyMathModeConversions(inner);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `\textbf{..}`, `\textit{..}`, `\emph{..}`, `\text{..}`,
|
||||
* `\mathrm{..}`, `\mathbf{..}`, `\mathit{..}`, `\mathsf{..}`, `\mathtt{..}`,
|
||||
* and `\operatorname{..}` into markdown-equivalent wrappers or plain text so
|
||||
* the regular inline parser picks them up downstream.
|
||||
*
|
||||
* Only handles a single level of nesting (no inner braces) — this keeps the
|
||||
* regex bounded and avoids catastrophic backtracking on adversarial input.
|
||||
*/
|
||||
function convertTextFormatting(text: string): string {
|
||||
let out = text;
|
||||
out = out.replace(
|
||||
/\\(?:textbf|mathbf)\{([^{}]*)\}/g,
|
||||
(_, inner: string) => `**${inner}**`,
|
||||
);
|
||||
out = out.replace(
|
||||
/\\(?:textit|emph|mathit)\{([^{}]*)\}/g,
|
||||
(_, inner: string) => `*${inner}*`,
|
||||
);
|
||||
out = out.replace(
|
||||
/\\(?:text|mathrm|mathsf|mathtt|mathbb|mathcal|mathfrak|operatorname)\{([^{}]*)\}/g,
|
||||
(_, inner: string) => inner,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `\frac{a}{b}` → `(a)/(b)` and `\sqrt{x}` → `√(x)`.
|
||||
* Only a single level of braces is supported.
|
||||
*/
|
||||
function convertFractionsAndRoots(text: string): string {
|
||||
let out = text;
|
||||
out = out.replace(
|
||||
/\\frac\{([^{}]*)\}\{([^{}]*)\}/g,
|
||||
(_, num: string, den: string) => `(${num})/(${den})`,
|
||||
);
|
||||
out = out.replace(
|
||||
/\\sqrt\[([^\]]*)\]\{([^{}]*)\}/g,
|
||||
(_, index: string, radicand: string) => `${index}√(${radicand})`,
|
||||
);
|
||||
out = out.replace(
|
||||
/\\sqrt\{([^{}]*)\}/g,
|
||||
(_, radicand: string) => `√(${radicand})`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts escaped single-character specials (`\{` → `{`, `\_` → `_`, etc.).
|
||||
* Runs before command lookup so `\{` is not misread as a command named `{`.
|
||||
*/
|
||||
function convertEscapedSpecials(text: string): string {
|
||||
// The set is intentionally narrow: only characters that have meaning in
|
||||
// LaTeX and also appear unescaped in plain text. We do not unescape `\\`
|
||||
// (line break) here — it is handled separately.
|
||||
let out = text.replace(/\\([{}[\]_%&#$|])/g, (_, ch: string) => ch);
|
||||
// `\ ` (backslash + space) is LaTeX for a non-breaking space; just keep it
|
||||
// as a regular space so words do not collide.
|
||||
out = out.replace(/\\ /g, ' ');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts named commands (alphabetic control sequences) to Unicode. Anything
|
||||
* not in the tables is left as-is so unrelated backslash content
|
||||
* (e.g. Windows paths) is not disturbed.
|
||||
*/
|
||||
function convertNamedCommands(text: string): string {
|
||||
return text.replace(
|
||||
/\\([A-Za-z]+)(?![A-Za-z])/g,
|
||||
(match, name: string) =>
|
||||
GREEK_LETTERS[name] ?? LATEX_COMMANDS[name] ?? match,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the short-form punctuation commands `\,`, `\;`, `\:`, `\!` used
|
||||
* for spacing in LaTeX. These are handled separately from alphabetic commands
|
||||
* because the regex for the latter only matches letters.
|
||||
*/
|
||||
function convertPunctuationCommands(text: string): string {
|
||||
// `\,`, `\;`, `\:` all render as a single space; `\!` is a negative space
|
||||
// and is stripped.
|
||||
return text.replace(/\\([,;:!])/g, (_, ch: string) => {
|
||||
switch (ch) {
|
||||
case ',':
|
||||
case ';':
|
||||
case ':':
|
||||
return ' ';
|
||||
case '!':
|
||||
return '';
|
||||
default:
|
||||
return ch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the `\\` line-break command (used inside math environments and
|
||||
* tables) to a literal newline. Must run after `\` specials but before any
|
||||
* other regex that might see a lingering backslash.
|
||||
*/
|
||||
function convertLineBreaks(text: string): string {
|
||||
return text.replace(/\\\\/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts subscripts and superscripts to Unicode where every character in
|
||||
* the operand maps. If any character has no mapping the whole operand is
|
||||
* left alone, to avoid "half-converted" output that looks worse than no
|
||||
* conversion.
|
||||
*/
|
||||
function convertSubSuperScripts(text: string): string {
|
||||
// Braced form first: x_{...}, x^{...}. We only support BMP characters (the
|
||||
// mapping tables are ASCII-only), so iterating with `Array.from` over code
|
||||
// units is safe and keeps the lint rule against splitting strings happy.
|
||||
const charsOf = (s: string): string[] => Array.from(s);
|
||||
|
||||
let out = text.replace(/_\{([^{}]+)\}/g, (match, inner: string) => {
|
||||
const chars = charsOf(inner);
|
||||
if (chars.every((c) => SUBSCRIPT_MAP[c] !== undefined)) {
|
||||
return chars.map((c) => SUBSCRIPT_MAP[c]).join('');
|
||||
}
|
||||
return match;
|
||||
});
|
||||
out = out.replace(/\^\{([^{}]+)\}/g, (match, inner: string) => {
|
||||
const chars = charsOf(inner);
|
||||
if (chars.every((c) => SUPERSCRIPT_MAP[c] !== undefined)) {
|
||||
return chars.map((c) => SUPERSCRIPT_MAP[c]).join('');
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Single-character form: x_0, x^2. Only convert when the character actually
|
||||
// has a mapping — leaves `file_name` and `foo^bar` alone.
|
||||
out = out.replace(
|
||||
/([A-Za-z0-9)\]])_([A-Za-z0-9+\-=()])/g,
|
||||
(match, base: string, c: string) => {
|
||||
const sub = SUBSCRIPT_MAP[c];
|
||||
return sub ? `${base}${sub}` : match;
|
||||
},
|
||||
);
|
||||
out = out.replace(
|
||||
/([A-Za-z0-9)\]])\^([A-Za-z0-9+\-=()])/g,
|
||||
(match, base: string, c: string) => {
|
||||
const sup = SUPERSCRIPT_MAP[c];
|
||||
return sup ? `${base}${sup}` : match;
|
||||
},
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the full set of conversions that make sense inside a LaTeX math
|
||||
* region (i.e. text that was originally wrapped in `$...$`). This includes
|
||||
* sub/superscripts, which are NOT safe to apply to arbitrary prose because
|
||||
* they would mangle identifiers like `file_name`.
|
||||
*/
|
||||
function applyMathModeConversions(text: string): string {
|
||||
let out = text;
|
||||
out = convertTextFormatting(out);
|
||||
out = convertFractionsAndRoots(out);
|
||||
out = convertEscapedSpecials(out);
|
||||
out = convertLineBreaks(out);
|
||||
out = convertNamedCommands(out);
|
||||
out = convertPunctuationCommands(out);
|
||||
out = convertSubSuperScripts(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies conversions that are safe to run on arbitrary prose — anything
|
||||
* keyed off explicit LaTeX tokens like `\alpha`, `\textbf{...}`, `\to`. Does
|
||||
* NOT touch standalone `_` or `^` so identifiers and snake_case names are
|
||||
* preserved.
|
||||
*/
|
||||
function applyProseConversions(text: string): string {
|
||||
let out = text;
|
||||
out = convertTextFormatting(out);
|
||||
out = convertFractionsAndRoots(out);
|
||||
out = convertEscapedSpecials(out);
|
||||
// Deliberately NOT running convertLineBreaks here: outside math delimiters
|
||||
// `\\` is far more likely to be a Windows UNC path (`\\server\share`) or an
|
||||
// escaped backslash in code-like prose than a LaTeX line break. Legitimate
|
||||
// LaTeX line breaks belong inside `$...$` or `$$...$$` and are handled by
|
||||
// applyMathModeConversions. See PR #25802 review.
|
||||
out = convertNamedCommands(out);
|
||||
out = convertPunctuationCommands(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level entry point. Two-phase conversion:
|
||||
*
|
||||
* 1. Strip `$...$` / `$$...$$` math regions, applying math-mode conversions
|
||||
* (including sub/superscripts) to the inner text. The heuristic for
|
||||
* "this dollar pair is math" runs against the ORIGINAL input so that
|
||||
* model-authored LaTeX is recognised before any tokens are rewritten.
|
||||
*
|
||||
* 2. Run prose-safe conversions over the remaining text, catching
|
||||
* unwrapped LaTeX tokens (`\alpha`, `\to`, `\textbf{...}`) that the
|
||||
* model emitted outside math delimiters.
|
||||
*
|
||||
* Short-circuits on input that has no LaTeX markers at all (`\` or `$`) so
|
||||
* the hot rendering path stays cheap for ordinary prose.
|
||||
*/
|
||||
export function convertLatexToUnicode(input: string): string {
|
||||
if (!input) return input;
|
||||
// Fast path: if there's no backslash and no dollar sign, there's nothing to
|
||||
// convert. This keeps the hot rendering path inexpensive for ordinary text.
|
||||
if (input.indexOf('\\') === -1 && input.indexOf('$') === -1) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let text = input;
|
||||
text = stripMathDelimiters(text);
|
||||
text = applyProseConversions(text);
|
||||
return text;
|
||||
}
|
||||
@@ -222,5 +222,52 @@ describe('parsingUtils', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('LaTeX conversion (issue #25656)', () => {
|
||||
it('converts LaTeX in plain text (no markdown tokens)', () => {
|
||||
const input = 'No cycles $\\to$ no deadlock';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(primary('No cycles → no deadlock'));
|
||||
});
|
||||
|
||||
it('converts LaTeX in the set example from the issue', () => {
|
||||
const input = 'Processes $\\{P_0, \\dots, P_n\\}$';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(primary('Processes {P₀, …, Pₙ}'));
|
||||
});
|
||||
|
||||
it('preserves LaTeX inside inline code', () => {
|
||||
// Content between backticks must be rendered verbatim — conversion
|
||||
// must NOT be applied inside code spans, even when the code contains
|
||||
// `$...$` that would otherwise be stripped.
|
||||
const input = 'use `$\\to$` for an arrow';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(
|
||||
`${primary('use ')}${accent('$\\to$')}${primary(' for an arrow')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('converts LaTeX in slices around markdown tokens', () => {
|
||||
const input = '$\\alpha$ is **bold** and $\\beta$ is plain';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(
|
||||
`${primary('α is ')}${chalk.bold(primary('bold'))}${primary(
|
||||
' and β is plain',
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves Windows paths alone', () => {
|
||||
const input = 'Path: C:\\Users\\foo';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(primary('Path: C:\\Users\\foo'));
|
||||
});
|
||||
|
||||
it('leaves currency amounts alone', () => {
|
||||
const input = 'It costs $5.99 total';
|
||||
const output = parseMarkdownToANSI(input);
|
||||
expect(output).toBe(primary('It costs $5.99 total'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../themes/color-utils.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { convertLatexToUnicode } from './latexToUnicode.js';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
const BOLD_MARKER_LENGTH = 2; // For "**"
|
||||
@@ -72,11 +73,49 @@ const ansiColorize = (str: string, color: string | undefined): string => {
|
||||
* Converts markdown text into a string with ANSI escape codes.
|
||||
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
|
||||
*/
|
||||
// Private-Use-Area codepoint used as a placeholder sentinel when masking
|
||||
// inline code / URL spans from LaTeX conversion. Not touched by
|
||||
// stripUnsafeCharacters and not matched by the markdown tokenizer.
|
||||
const MASK_SENTINEL = '\uE000';
|
||||
const MASK_PATTERN = /\uE000(\d+)\uE000/g;
|
||||
|
||||
/**
|
||||
* Runs LaTeX conversion on `text` while keeping inline code spans and bare
|
||||
* URLs verbatim. Without masking, the LaTeX pass would happily rewrite
|
||||
* ``$\to$`` inside a backtick code span — violating the "code is verbatim"
|
||||
* contract — and could rewrite URL query strings containing `$`.
|
||||
*/
|
||||
const convertLatexPreservingSpans = (text: string): string => {
|
||||
const preserved: string[] = [];
|
||||
// Match inline code spans (with matched backtick counts) and bare URLs.
|
||||
// Order matters: code spans first so they win over a URL inside a span.
|
||||
const masked = text.replace(/(`+)([^`\n]+?)\1|https?:\/\/\S+/g, (match) => {
|
||||
const index = preserved.push(match) - 1;
|
||||
return `${MASK_SENTINEL}${index}${MASK_SENTINEL}`;
|
||||
});
|
||||
const converted = convertLatexToUnicode(masked);
|
||||
return converted.replace(
|
||||
MASK_PATTERN,
|
||||
// Fallback to the literal match if the index is somehow out of range —
|
||||
// defensive against the unlikely case where the PUA sentinel appears in
|
||||
// user input. Without the fallback, replace would emit "undefined".
|
||||
(match, i: string) => preserved[Number(i)] ?? match,
|
||||
);
|
||||
};
|
||||
|
||||
export const parseMarkdownToANSI = (
|
||||
text: string,
|
||||
rawText: string,
|
||||
defaultColor?: string,
|
||||
): string => {
|
||||
const baseColor = defaultColor ?? theme.text.primary;
|
||||
// Convert LaTeX-style math/commands to Unicode BEFORE tokenizing markdown,
|
||||
// so constructs like `$\{P_0, \dots, P_n\}$` are handled as a whole even
|
||||
// when they contain underscores (which the tokenizer would otherwise treat
|
||||
// as italic markers). Inline code and URLs are masked during the
|
||||
// conversion so their contents are preserved verbatim. Unknown `\foo`
|
||||
// sequences are left alone, so Windows paths and regex escapes survive.
|
||||
// See issue #25656.
|
||||
const text = convertLatexPreservingSpans(rawText);
|
||||
// Early return for plain text without markdown or URLs
|
||||
if (!/[*_~`<[https?:]/.test(text)) {
|
||||
return ansiColorize(text, baseColor);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
captureHeapSnapshot,
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
|
||||
} from './memorySnapshot.js';
|
||||
|
||||
const { mkdirMock, pipelineMock, getHeapSnapshotMock, createWriteStreamMock } =
|
||||
vi.hoisted(() => ({
|
||||
mkdirMock: vi.fn(async () => undefined),
|
||||
pipelineMock: vi.fn(async () => undefined),
|
||||
getHeapSnapshotMock: vi.fn(),
|
||||
createWriteStreamMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return { ...actual, mkdir: mkdirMock };
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, createWriteStream: createWriteStreamMock };
|
||||
});
|
||||
|
||||
vi.mock('node:v8', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:v8')>();
|
||||
return { ...actual, getHeapSnapshot: getHeapSnapshotMock };
|
||||
});
|
||||
|
||||
vi.mock('node:stream/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:stream/promises')>();
|
||||
return { ...actual, pipeline: pipelineMock };
|
||||
});
|
||||
|
||||
describe('captureHeapSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockClear();
|
||||
pipelineMock.mockClear();
|
||||
getHeapSnapshotMock.mockClear().mockReturnValue(Readable.from([]));
|
||||
createWriteStreamMock
|
||||
.mockClear()
|
||||
.mockReturnValue({ write: vi.fn(), end: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exports the 2 GB auto-capture threshold', () => {
|
||||
expect(MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('creates the target directory and pipelines the V8 snapshot to disk', async () => {
|
||||
const target = '/tmp/gemini-test/snapshot.heapsnapshot';
|
||||
|
||||
await captureHeapSnapshot(target);
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gemini-test', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(getHeapSnapshotMock).toHaveBeenCalledTimes(1);
|
||||
expect(createWriteStreamMock).toHaveBeenCalledWith(target);
|
||||
expect(pipelineMock).toHaveBeenCalledTimes(1);
|
||||
expect(pipelineMock).toHaveBeenCalledWith(
|
||||
getHeapSnapshotMock.mock.results[0].value,
|
||||
createWriteStreamMock.mock.results[0].value,
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates pipeline failures to the caller', async () => {
|
||||
pipelineMock.mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await expect(
|
||||
captureHeapSnapshot('/tmp/gemini-test/fail.heapsnapshot'),
|
||||
).rejects.toThrow('write failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { getHeapSnapshot } from 'node:v8';
|
||||
|
||||
/**
|
||||
* RSS threshold at which `/bug` auto-captures a heap snapshot.
|
||||
*/
|
||||
export const MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Capture a V8 heap snapshot from the current process and write it to disk.
|
||||
*
|
||||
* `v8.getHeapSnapshot()` returns a Readable stream whose producer is V8's
|
||||
* internal snapshot generator. Piping it through `node:stream/promises`'
|
||||
* `pipeline` propagates backpressure end-to-end, so even a multi-gigabyte
|
||||
* heap is written without buffering the serialized snapshot in memory.
|
||||
* Nothing is exposed over a debugger port.
|
||||
*/
|
||||
export async function captureHeapSnapshot(filePath: string): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await pipeline(getHeapSnapshot(), createWriteStream(filePath));
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock os.homedir to control the home directory in tests
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(),
|
||||
homedir: vi.fn(() => actualOs.homedir()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => os.homedir(),
|
||||
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false),
|
||||
WarningPriority: {
|
||||
@@ -66,6 +65,7 @@ describe('getUserStartupWarnings', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testRootDir, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -98,6 +98,54 @@ describe('getUserStartupWarnings', () => {
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when running in a subdirectory of home', async () => {
|
||||
const subDir = path.join(homeDir, 'projects', 'my-app');
|
||||
await fs.mkdir(subDir, { recursive: true });
|
||||
const warnings = await getUserStartupWarnings({}, subDir);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => {
|
||||
const realHome = path.join(testRootDir, 'real-home');
|
||||
await fs.mkdir(realHome, { recursive: true });
|
||||
const symlinkedHome = path.join(testRootDir, 'symlinked-home');
|
||||
await fs.symlink(realHome, symlinkedHome);
|
||||
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
|
||||
|
||||
const subDir = path.join(symlinkedHome, 'projects');
|
||||
await fs.mkdir(subDir, { recursive: true });
|
||||
const warnings = await getUserStartupWarnings({}, subDir);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a warning when home directory is a symlink and running in it', async () => {
|
||||
const realHome = path.join(testRootDir, 'real-home2');
|
||||
await fs.mkdir(realHome, { recursive: true });
|
||||
const symlinkedHome = path.join(testRootDir, 'symlinked-home2');
|
||||
await fs.symlink(realHome, symlinkedHome);
|
||||
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, symlinkedHome);
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'home-directory',
|
||||
message: expect.stringContaining(
|
||||
'Warning you are running Gemini CLI in your home directory',
|
||||
),
|
||||
priority: WarningPriority.Low,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => {
|
||||
const projectDir = path.join(testRootDir, 'project');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
vi.stubEnv('GEMINI_CLI_HOME', projectDir);
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, projectDir);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
|
||||
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir as osHomedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
homedir,
|
||||
getCompatibilityWarnings,
|
||||
WarningPriority,
|
||||
type StartupWarning,
|
||||
@@ -39,10 +39,10 @@ const homeDirectoryCheck: WarningCheck = {
|
||||
try {
|
||||
const [workspaceRealPath, homeRealPath] = await Promise.all([
|
||||
fs.realpath(workspaceRoot),
|
||||
fs.realpath(homedir()),
|
||||
fs.realpath(osHomedir()),
|
||||
]);
|
||||
|
||||
if (workspaceRealPath === homeRealPath) {
|
||||
if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) {
|
||||
// If folder trust is enabled and the user trusts the home directory, don't show the warning.
|
||||
if (
|
||||
isFolderTrustEnabled(settings) &&
|
||||
|
||||
@@ -208,12 +208,20 @@ vi.mock('../config/scoped-config.js', async (importOriginal) => {
|
||||
...actual,
|
||||
runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext),
|
||||
createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext),
|
||||
runWithScopedAutoMemoryExtractionWriteAccess: vi.fn(
|
||||
actual.runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
),
|
||||
runWithScopedMemoryInboxAccess: vi.fn(
|
||||
actual.runWithScopedMemoryInboxAccess,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
runWithScopedWorkspaceContext,
|
||||
createScopedWorkspaceContext,
|
||||
runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
runWithScopedMemoryInboxAccess,
|
||||
} from '../config/scoped-config.js';
|
||||
const mockedRunWithScopedWorkspaceContext = vi.mocked(
|
||||
runWithScopedWorkspaceContext,
|
||||
@@ -221,6 +229,12 @@ const mockedRunWithScopedWorkspaceContext = vi.mocked(
|
||||
const mockedCreateScopedWorkspaceContext = vi.mocked(
|
||||
createScopedWorkspaceContext,
|
||||
);
|
||||
const mockedRunWithScopedMemoryInboxAccess = vi.mocked(
|
||||
runWithScopedMemoryInboxAccess,
|
||||
);
|
||||
const mockedRunWithScopedAutoMemoryExtractionWriteAccess = vi.mocked(
|
||||
runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
);
|
||||
|
||||
const MockedGeminiChat = vi.mocked(GeminiChat);
|
||||
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
|
||||
@@ -422,6 +436,8 @@ describe('LocalAgentExecutor', () => {
|
||||
mockedLogAgentFinish.mockReset();
|
||||
mockedRunWithScopedWorkspaceContext.mockClear();
|
||||
mockedCreateScopedWorkspaceContext.mockClear();
|
||||
mockedRunWithScopedMemoryInboxAccess.mockClear();
|
||||
mockedRunWithScopedAutoMemoryExtractionWriteAccess.mockClear();
|
||||
mockedPromptIdContext.getStore.mockReset();
|
||||
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
|
||||
|
||||
@@ -941,6 +957,52 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should use runWithScopedMemoryInboxAccess when memoryInboxAccess is set', async () => {
|
||||
const definition = createTestDefinition();
|
||||
definition.memoryInboxAccess = true;
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: COMPLETE_TASK_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
expect(mockedRunWithScopedMemoryInboxAccess).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should use the extraction write scope when autoMemoryExtractionWriteAccess is set', async () => {
|
||||
const definition = createTestDefinition();
|
||||
definition.autoMemoryExtractionWriteAccess = true;
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: COMPLETE_TASK_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
expect(
|
||||
mockedRunWithScopedAutoMemoryExtractionWriteAccess,
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => {
|
||||
const definition = createTestDefinition();
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
@@ -962,6 +1024,10 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled();
|
||||
expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled();
|
||||
expect(mockedRunWithScopedMemoryInboxAccess).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockedRunWithScopedAutoMemoryExtractionWriteAccess,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ import {
|
||||
import type { InjectionSource } from '../config/injectionService.js';
|
||||
import {
|
||||
createScopedWorkspaceContext,
|
||||
runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
runWithScopedMemoryInboxAccess,
|
||||
runWithScopedWorkspaceContext,
|
||||
} from '../config/scoped-config.js';
|
||||
import { CompleteTaskTool } from '../tools/complete-task.js';
|
||||
@@ -529,21 +531,34 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
* @returns A promise that resolves to the agent's final output.
|
||||
*/
|
||||
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
|
||||
// If the agent definition declares additional workspace directories,
|
||||
// wrap execution in a scoped workspace context. All calls to
|
||||
// Config.getWorkspaceContext() within this scope will see the extended
|
||||
// directories, without mutating the shared Config.
|
||||
const dirs = this.definition.workspaceDirectories;
|
||||
if (dirs && dirs.length > 0) {
|
||||
const scopedCtx = createScopedWorkspaceContext(
|
||||
this.context.config.getWorkspaceContext(),
|
||||
dirs,
|
||||
);
|
||||
return runWithScopedWorkspaceContext(scopedCtx, () =>
|
||||
this.runInternal(inputs, signal),
|
||||
);
|
||||
const runWithWorkspaceScope = () => {
|
||||
// If the agent definition declares additional workspace directories,
|
||||
// wrap execution in a scoped workspace context. All calls to
|
||||
// Config.getWorkspaceContext() within this scope will see the extended
|
||||
// directories, without mutating the shared Config.
|
||||
const dirs = this.definition.workspaceDirectories;
|
||||
if (dirs && dirs.length > 0) {
|
||||
const scopedCtx = createScopedWorkspaceContext(
|
||||
this.context.config.getWorkspaceContext(),
|
||||
dirs,
|
||||
);
|
||||
return runWithScopedWorkspaceContext(scopedCtx, () =>
|
||||
this.runInternal(inputs, signal),
|
||||
);
|
||||
}
|
||||
return this.runInternal(inputs, signal);
|
||||
};
|
||||
|
||||
const runWithInboxScope = () =>
|
||||
this.definition.memoryInboxAccess
|
||||
? runWithScopedMemoryInboxAccess(runWithWorkspaceScope)
|
||||
: runWithWorkspaceScope();
|
||||
|
||||
if (this.definition.autoMemoryExtractionWriteAccess) {
|
||||
return runWithScopedAutoMemoryExtractionWriteAccess(runWithInboxScope);
|
||||
}
|
||||
return this.runInternal(inputs, signal);
|
||||
|
||||
return runWithInboxScope();
|
||||
}
|
||||
|
||||
private async runInternal(
|
||||
|
||||
@@ -459,7 +459,7 @@ describe('AgentRegistry', () => {
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
// Verify ackService was called with the URL, not the file hash
|
||||
// Verify ackService was called with the raw URL to avoid breaking changes
|
||||
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'RemoteAgent',
|
||||
@@ -467,7 +467,6 @@ describe('AgentRegistry', () => {
|
||||
);
|
||||
|
||||
// Also verify that the agent's metadata was updated to use the URL as hash
|
||||
// Use getDefinition because registerAgent might have been called
|
||||
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
|
||||
'https://example.com/card',
|
||||
);
|
||||
|
||||
@@ -8,7 +8,11 @@ import * as crypto from 'node:crypto';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||
import type { AgentOverride, Config } from '../config/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type LocalAgentDefinition,
|
||||
type AgentReloadSummary,
|
||||
} from './types.js';
|
||||
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
|
||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
@@ -80,13 +84,53 @@ export class AgentRegistry {
|
||||
/**
|
||||
* Clears the current registry and re-scans for agents.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
async reload(): Promise<AgentReloadSummary> {
|
||||
const previousAgents = new Map(this.agents);
|
||||
const reloadErrors: string[] = [];
|
||||
|
||||
this.config.getA2AClientManager()?.clearCache();
|
||||
await this.config.reloadAgents();
|
||||
this.agents.clear();
|
||||
this.allDefinitions.clear();
|
||||
await this.loadAgents();
|
||||
await this.loadAgents(reloadErrors);
|
||||
|
||||
const currentAgents = Array.from(this.agents.values());
|
||||
const newAgents: string[] = [];
|
||||
const updatedAgents: string[] = [];
|
||||
const deletedAgents: string[] = [];
|
||||
let localCount = 0;
|
||||
let remoteCount = 0;
|
||||
|
||||
for (const agent of currentAgents) {
|
||||
if (agent.kind === 'local') {
|
||||
localCount++;
|
||||
} else if (agent.kind === 'remote') {
|
||||
remoteCount++;
|
||||
}
|
||||
|
||||
const prev = previousAgents.get(agent.name);
|
||||
if (!prev) {
|
||||
newAgents.push(agent.name);
|
||||
} else if (agent.metadata?.hash !== prev.metadata?.hash) {
|
||||
updatedAgents.push(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prevName of previousAgents.keys()) {
|
||||
if (!this.agents.has(prevName)) {
|
||||
deletedAgents.push(prevName);
|
||||
}
|
||||
}
|
||||
|
||||
coreEvents.emitAgentsRefreshed();
|
||||
|
||||
return {
|
||||
totalLoaded: currentAgents.length,
|
||||
localCount,
|
||||
remoteCount,
|
||||
newAgents,
|
||||
updatedAgents,
|
||||
deletedAgents,
|
||||
errors: reloadErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +157,7 @@ export class AgentRegistry {
|
||||
coreEvents.off(CoreEvent.ModelChanged, this.onModelChanged);
|
||||
}
|
||||
|
||||
private async loadAgents(): Promise<void> {
|
||||
private async loadAgents(errors?: string[]): Promise<void> {
|
||||
this.agents.clear();
|
||||
this.allDefinitions.clear();
|
||||
this.loadBuiltInAgents();
|
||||
@@ -132,21 +176,20 @@ export class AgentRegistry {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error loading user agent: ${error.message}`,
|
||||
);
|
||||
coreEvents.emitFeedback('error', `Agent loading error: ${error.message}`);
|
||||
const msg = `Agent loading error: ${error.message}`;
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
}
|
||||
await Promise.allSettled(
|
||||
userAgents.agents.map(async (agent) => {
|
||||
try {
|
||||
await this.registerAgent(agent);
|
||||
this.ensureRemoteAgentHash(agent);
|
||||
await this.registerAgent(agent, errors);
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error registering user agent "${agent.name}":`,
|
||||
e,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
const msg = `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
|
||||
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -159,10 +202,9 @@ export class AgentRegistry {
|
||||
const projectAgentsDir = this.config.storage.getProjectAgentsDir();
|
||||
const projectAgents = await loadAgentsFromDirectory(projectAgentsDir);
|
||||
for (const error of projectAgents.errors) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Agent loading error: ${error.message}`,
|
||||
);
|
||||
const msg = `Agent loading error: ${error.message}`;
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
}
|
||||
|
||||
const ackService = this.config.getAcknowledgedAgentsService();
|
||||
@@ -171,21 +213,7 @@ export class AgentRegistry {
|
||||
const agentsToRegister: AgentDefinition[] = [];
|
||||
|
||||
for (const agent of projectAgents.agents) {
|
||||
// If it's a remote agent, use the agentCardUrl as the hash.
|
||||
// This allows multiple remote agents in a single file to be tracked independently.
|
||||
if (agent.kind === 'remote') {
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
}
|
||||
agent.metadata.hash =
|
||||
agent.agentCardUrl ??
|
||||
(agent.agentCardJson
|
||||
? crypto
|
||||
.createHash('sha256')
|
||||
.update(agent.agentCardJson)
|
||||
.digest('hex')
|
||||
: undefined);
|
||||
}
|
||||
this.ensureRemoteAgentHash(agent);
|
||||
|
||||
if (!agent.metadata?.hash) {
|
||||
agentsToRegister.push(agent);
|
||||
@@ -212,16 +240,12 @@ export class AgentRegistry {
|
||||
await Promise.allSettled(
|
||||
agentsToRegister.map(async (agent) => {
|
||||
try {
|
||||
await this.registerAgent(agent);
|
||||
await this.registerAgent(agent, errors);
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error registering project agent "${agent.name}":`,
|
||||
e,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
const msg = `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
|
||||
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -238,16 +262,12 @@ export class AgentRegistry {
|
||||
await Promise.allSettled(
|
||||
extension.agents.map(async (agent) => {
|
||||
try {
|
||||
await this.registerAgent(agent);
|
||||
await this.registerAgent(agent, errors);
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error registering extension agent "${agent.name}":`,
|
||||
e,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
const msg = `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
|
||||
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -314,11 +334,12 @@ export class AgentRegistry {
|
||||
*/
|
||||
protected async registerAgent<TOutput extends z.ZodTypeAny>(
|
||||
definition: AgentDefinition<TOutput>,
|
||||
errors?: string[],
|
||||
): Promise<void> {
|
||||
if (definition.kind === 'local') {
|
||||
this.registerLocalAgent(definition);
|
||||
} else if (definition.kind === 'remote') {
|
||||
await this.registerRemoteAgent(definition);
|
||||
await this.registerRemoteAgent(definition, errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +437,7 @@ export class AgentRegistry {
|
||||
*/
|
||||
protected async registerRemoteAgent<TOutput extends z.ZodTypeAny>(
|
||||
definition: AgentDefinition<TOutput>,
|
||||
errors?: string[],
|
||||
): Promise<void> {
|
||||
if (definition.kind !== 'remote') {
|
||||
return;
|
||||
@@ -544,17 +566,14 @@ export class AgentRegistry {
|
||||
this.addAgentPolicy(definition);
|
||||
} catch (e) {
|
||||
// Surface structured, user-friendly error messages for known failure modes.
|
||||
let msg: string;
|
||||
if (e instanceof A2AAgentError) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`[${definition.name}] ${e.userMessage}`,
|
||||
);
|
||||
msg = `[${definition.name}] ${e.userMessage}`;
|
||||
} else {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
msg = `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
errors?.push(msg);
|
||||
coreEvents.emitFeedback('error', msg);
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
|
||||
e,
|
||||
@@ -704,4 +723,28 @@ export class AgentRegistry {
|
||||
getDiscoveredDefinition(name: string): AgentDefinition | undefined {
|
||||
return this.allDefinitions.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that remote agents have a content-based hash for trust verification and change detection.
|
||||
*/
|
||||
private ensureRemoteAgentHash(agent: AgentDefinition): void {
|
||||
if (agent.kind !== 'remote') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
}
|
||||
|
||||
// To avoid a breaking change for existing users, we continue to use
|
||||
// the raw URL as the hash for URL-based remote agents.
|
||||
if (agent.agentCardUrl) {
|
||||
agent.metadata.hash = agent.agentCardUrl;
|
||||
} else if (agent.agentCardJson) {
|
||||
agent.metadata.hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(agent.agentCardJson)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
@@ -34,6 +35,8 @@ describe('SkillExtractionAgent', () => {
|
||||
expect(agent.name).toBe('confucius');
|
||||
expect(agent.displayName).toBe('Skill Extractor');
|
||||
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
expect(agent.memoryInboxAccess).toBe(true);
|
||||
expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
|
||||
expect(agent.toolConfig?.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
READ_FILE_TOOL_NAME,
|
||||
@@ -44,6 +47,7 @@ describe('SkillExtractionAgent', () => {
|
||||
GREP_TOOL_NAME,
|
||||
]),
|
||||
);
|
||||
expect(agent.toolConfig?.tools).not.toContain(SHELL_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should default to no skill unless recurrence and durability are proven', () => {
|
||||
@@ -69,6 +73,104 @@ describe('SkillExtractionAgent', () => {
|
||||
expect(prompt).toContain('cannot survive renaming the specific');
|
||||
});
|
||||
|
||||
it('should require all memory updates to go through .inbox/<kind>/*.patch for review', () => {
|
||||
const prompt = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
'/tmp/memory',
|
||||
).promptConfig.systemPrompt;
|
||||
|
||||
expect(prompt).toContain(
|
||||
'ALL memory updates are expressed as unified diff `.patch` files',
|
||||
);
|
||||
expect(prompt).toContain('EXACTLY ONE canonical patch file per kind');
|
||||
expect(prompt).toContain('extraction.patch');
|
||||
expect(prompt).not.toContain('MEMORY.patch');
|
||||
expect(prompt).not.toContain('verify-workflow.patch');
|
||||
expect(prompt).toContain('IMPORTANT — incremental updates');
|
||||
expect(prompt).toContain(
|
||||
'REWRITE that file by combining its existing hunks with your new',
|
||||
);
|
||||
expect(prompt).toContain('private ->');
|
||||
expect(prompt).toContain('global ->');
|
||||
expect(prompt).toContain(
|
||||
'the target MUST be exactly the single global personal memory',
|
||||
);
|
||||
expect(prompt).toContain('~/.gemini/GEMINI.md');
|
||||
expect(prompt).not.toContain('memory.md');
|
||||
expect(prompt).not.toContain('and siblings');
|
||||
expect(prompt).toContain(
|
||||
'Project/workspace shared instructions (GEMINI.md and similar files',
|
||||
);
|
||||
expect(prompt).toContain('MEMORY PATCH FORMAT (STRICT)');
|
||||
expect(prompt).toContain('--- /dev/null');
|
||||
expect(prompt).toContain('NEVER directly edit MEMORY.md');
|
||||
expect(prompt).toContain(
|
||||
'Every patch you write is held for /memory inbox review.',
|
||||
);
|
||||
expect(prompt).toContain('the user must approve each patch');
|
||||
|
||||
// The MEMORY.md-as-index discipline: sibling creations should pair with
|
||||
// a MEMORY.md update hunk; the inbox apply step auto-bundles a generic
|
||||
// pointer if the agent forgets, but the agent should write its own.
|
||||
expect(prompt).toContain('PRIVATE MEMORY: MEMORY.md IS THE INDEX');
|
||||
expect(prompt).toContain(
|
||||
'when you create a new sibling .md file, your patch SHOULD',
|
||||
);
|
||||
expect(prompt).toContain('a SECOND HUNK that updates MEMORY.md');
|
||||
expect(prompt).toContain('inbox apply step');
|
||||
expect(prompt).toContain('auto-bundle a generic pointer');
|
||||
|
||||
// Pointer paths must be ABSOLUTE — the runtime agent reads them directly.
|
||||
expect(prompt).toContain('IMPORTANT — pointer paths must be ABSOLUTE');
|
||||
expect(prompt).toContain('Always write the full path');
|
||||
// The example pointer in the prompt also uses the absolute path.
|
||||
expect(prompt).toContain(`+- See /tmp/memory/<topic>.md for`);
|
||||
});
|
||||
|
||||
it('surfaces existing inbox patches in the initial query when present', () => {
|
||||
const pendingInbox = [
|
||||
'## private (1)',
|
||||
'',
|
||||
'### extraction.patch',
|
||||
'```',
|
||||
'--- /dev/null',
|
||||
'+++ /tmp/memory/MEMORY.md',
|
||||
'@@ -0,0 +1,1 @@',
|
||||
'+- previously-extracted fact',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const agentWithInbox = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
'/tmp/memory',
|
||||
pendingInbox,
|
||||
);
|
||||
const query = agentWithInbox.promptConfig.query ?? '';
|
||||
|
||||
expect(query).toContain('# Pending Memory Inbox');
|
||||
expect(query).toContain('extraction.patch');
|
||||
expect(query).toContain('previously-extracted fact');
|
||||
expect(query).toContain(
|
||||
'REWRITE that patch (overwrite the same path) with',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the pending inbox section when nothing is pending', () => {
|
||||
const agentEmpty = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
'/tmp/memory',
|
||||
'',
|
||||
);
|
||||
const query = agentEmpty.promptConfig.query ?? '';
|
||||
expect(query).not.toContain('# Pending Memory Inbox');
|
||||
});
|
||||
|
||||
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
|
||||
const query = agent.promptConfig.query ?? '';
|
||||
|
||||
@@ -86,7 +188,10 @@ describe('SkillExtractionAgent', () => {
|
||||
'Only write a skill if the evidence shows a durable, recurring workflow',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'If recurrence or future reuse is unclear, create no skill and explain why.',
|
||||
'Only write memory if it would clearly help a future session.',
|
||||
);
|
||||
expect(query).toContain(
|
||||
'If recurrence, durability, or future reuse is unclear, create no artifact and explain why.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
@@ -21,20 +20,21 @@ import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
const SkillExtractionSchema = z.object({
|
||||
response: z
|
||||
.string()
|
||||
.describe('A summary of the skills extracted or updated.'),
|
||||
.describe('A summary of the memories or skills extracted or updated.'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the system prompt for the skill extraction agent.
|
||||
*/
|
||||
function buildSystemPrompt(skillsDir: string): string {
|
||||
function buildSystemPrompt(skillsDir: string, memoryDir: string): string {
|
||||
return [
|
||||
'You are a Skill Extraction Agent.',
|
||||
'You are an Auto Memory Extraction Agent.',
|
||||
'',
|
||||
'Your job: analyze past conversation sessions and extract reusable skills that will help',
|
||||
'future agents work more efficiently. You write SKILL.md files to a specific directory.',
|
||||
'Your job: analyze past conversation sessions and extract durable memory candidates',
|
||||
'and reusable skills that will help future agents work more efficiently.',
|
||||
'',
|
||||
'The goal is to help future agents:',
|
||||
'- remember durable project facts, preferences, and workflow constraints',
|
||||
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
|
||||
'- reuse proven workflows and verification checklists',
|
||||
'- avoid known failure modes and landmines',
|
||||
@@ -48,8 +48,131 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'- Evidence-based only: do not invent facts or claim verification that did not happen.',
|
||||
'- Redact secrets: never store tokens/keys/passwords; replace with [REDACTED].',
|
||||
'- Do not copy large tool outputs. Prefer compact summaries + exact error snippets.',
|
||||
` Write all files under this directory ONLY: ${skillsDir}`,
|
||||
' NEVER write files outside this directory. You may read session files from the paths provided in the index.',
|
||||
`- Write all files under this memory work directory ONLY: ${memoryDir}`,
|
||||
`- Reusable skill candidates go under: ${skillsDir}`,
|
||||
`- Reviewable memory candidates go under: ${memoryDir}/.inbox`,
|
||||
' NEVER write files outside the memory work directory. You may read session files from the paths provided in the index.',
|
||||
'',
|
||||
'============================================================',
|
||||
'MEMORY OUTPUTS',
|
||||
'============================================================',
|
||||
'',
|
||||
'ALL memory updates are expressed as unified diff `.patch` files. There is',
|
||||
`EXACTLY ONE canonical patch file per kind: ${memoryDir}/.inbox/<kind>/extraction.patch`,
|
||||
'where <kind> is one of:',
|
||||
'- private -> targets must live under the project memory directory',
|
||||
` (${memoryDir}). Use this for project-scoped private memory.`,
|
||||
'- global -> the target MUST be exactly the single global personal memory',
|
||||
' file ~/.gemini/GEMINI.md. No other files in ~/.gemini/ are',
|
||||
' writeable; sibling .md files do not exist for the global tier.',
|
||||
'',
|
||||
'IMPORTANT — incremental updates:',
|
||||
'- Before writing a new patch, check if "# Pending Memory Inbox" (above)',
|
||||
' already lists an `extraction.patch` for the same kind.',
|
||||
'- If yes: REWRITE that file by combining its existing hunks with your new',
|
||||
' ones (overwrite the same path with the merged multi-hunk patch). Do NOT',
|
||||
' create separate `topic-a.patch`, `topic-b.patch` files; everything goes',
|
||||
' in one canonical `extraction.patch` per kind.',
|
||||
'- If no: write a new `extraction.patch` with all your hunks.',
|
||||
'',
|
||||
'Project/workspace shared instructions (GEMINI.md and similar files under the',
|
||||
'project root) are NOT auto-extractable. They are managed by humans only; do',
|
||||
'not write patches that target files under the project root.',
|
||||
'',
|
||||
'NEVER directly edit MEMORY.md, GEMINI.md, ~/.gemini/GEMINI.md, settings,',
|
||||
'credentials, or any file outside the memory work directory. The only way to',
|
||||
'update memory is via a `.patch` file in the appropriate `.inbox/<kind>/` folder.',
|
||||
'',
|
||||
'Every patch you write is held for /memory inbox review. Nothing is applied',
|
||||
'automatically; the user must approve each patch before it touches active files.',
|
||||
'',
|
||||
'Private memory is for durable facts, preferences, decisions, and project context.',
|
||||
'Skills are only for reusable procedures. If both apply, avoid duplicating the same content.',
|
||||
'Default to no-op. Prefer 0-5 memory patches and 0-2 skills per run.',
|
||||
'',
|
||||
'============================================================',
|
||||
'PRIVATE MEMORY: MEMORY.md IS THE INDEX (CRITICAL)',
|
||||
'============================================================',
|
||||
'',
|
||||
`In <memoryDir> (${memoryDir}), only MEMORY.md is auto-loaded into future`,
|
||||
'agent contexts. Sibling .md files (e.g. verify-workflow.md, design-doc.md)',
|
||||
'are loaded ON DEMAND by the runtime agent via read_file ONLY when MEMORY.md',
|
||||
'references them.',
|
||||
'',
|
||||
'Therefore, when you create a new sibling .md file, your patch SHOULD',
|
||||
'include a SECOND HUNK that updates MEMORY.md to add a one-line pointer',
|
||||
'to the new file. The pointer is what makes the sibling discoverable to',
|
||||
'future agents.',
|
||||
'',
|
||||
'IMPORTANT — pointer paths must be ABSOLUTE. Future agents `read_file`',
|
||||
`directly off the pointer line, so the path must resolve without knowing`,
|
||||
`<memoryDir>. Always write the full path (${memoryDir}/<topic>.md), never`,
|
||||
'just the basename. The auto-bundle fallback also writes absolute paths.',
|
||||
'',
|
||||
'If you forget to include the MEMORY.md pointer, the inbox apply step',
|
||||
`will auto-bundle a generic pointer (\`- See ${memoryDir}/<name>.md for ...\`)`,
|
||||
'so the sibling is at least discoverable. But that auto-pointer is dumb —',
|
||||
'write the proper paired hunk yourself so MEMORY.md gets a meaningful',
|
||||
'summary.',
|
||||
'',
|
||||
'Correct shape for "create a new sibling" patch:',
|
||||
'',
|
||||
' --- /dev/null',
|
||||
` +++ ${memoryDir}/<topic>.md`,
|
||||
' @@ -0,0 +1,N @@',
|
||||
' +# <topic>',
|
||||
' +...',
|
||||
'',
|
||||
` --- ${memoryDir}/MEMORY.md`,
|
||||
` +++ ${memoryDir}/MEMORY.md`,
|
||||
' @@ -<line>,3 +<line>,4 @@',
|
||||
' <context>',
|
||||
' <context>',
|
||||
' <context>',
|
||||
` +- See ${memoryDir}/<topic>.md for <one-line summary>.`,
|
||||
'',
|
||||
'For brief facts (a few lines), prefer adding the entry directly to MEMORY.md',
|
||||
'as a single-hunk patch — no sibling file needed. Only spawn a sibling file',
|
||||
'when the content has substantial detail (multiple sections, procedures, etc.).',
|
||||
'',
|
||||
'============================================================',
|
||||
'MEMORY PATCH FORMAT (STRICT)',
|
||||
'============================================================',
|
||||
'',
|
||||
'Always read the target file first with read_file (or skip the read if the file',
|
||||
'definitely does not exist yet) so the patch context lines match exactly.',
|
||||
'',
|
||||
'Use one of these two unified diff shapes inside each `.patch` file:',
|
||||
'',
|
||||
'1. Update an existing file:',
|
||||
'',
|
||||
' --- /absolute/path/to/target.md',
|
||||
' +++ /absolute/path/to/target.md',
|
||||
' @@ -<oldStart>,<oldCount> +<newStart>,<newCount> @@',
|
||||
' <unchanged context line>',
|
||||
' -<removed line>',
|
||||
' +<added line>',
|
||||
' <unchanged context line>',
|
||||
'',
|
||||
'2. Create a brand-new file (no existing target):',
|
||||
'',
|
||||
' --- /dev/null',
|
||||
' +++ /absolute/path/to/new-target.md',
|
||||
' @@ -0,0 +1,<count> @@',
|
||||
' +<line 1>',
|
||||
' +<line 2>',
|
||||
'',
|
||||
'Patch rules:',
|
||||
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO `a/`/`b/` prefixes).',
|
||||
'- For updates, both headers must be the SAME absolute path.',
|
||||
'- Include 3 lines of context around each change for updates.',
|
||||
'- Line counts in @@ headers MUST be accurate.',
|
||||
'- One `.patch` file may include multiple hunks across multiple files in the same kind.',
|
||||
'- The patch FILENAME under .inbox/<kind>/ MUST be the canonical',
|
||||
' `extraction.patch`; the headers determine the actual target file(s).',
|
||||
'- Patches that fail validation or fail to apply cleanly are discarded silently.',
|
||||
"- The header path must resolve under the kind's allowed root (see above) or the",
|
||||
' patch will be rejected.',
|
||||
'',
|
||||
'============================================================',
|
||||
'NO-OP / MINIMUM SIGNAL GATE',
|
||||
@@ -212,8 +335,7 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
|
||||
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
|
||||
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
|
||||
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
|
||||
' skill directories and package_skill.cjs to validate finished skills.',
|
||||
' writing SKILL.md files.',
|
||||
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
|
||||
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
|
||||
' installation prompts, iteration loops). Use only its format and quality guidance.',
|
||||
@@ -228,15 +350,19 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
|
||||
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
|
||||
' why it is not generic.',
|
||||
'8. Write new SKILL.md files or update existing ones in your directory.',
|
||||
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
|
||||
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
|
||||
'9. Write COMPLETE files — never partially update a SKILL.md.',
|
||||
'8. For memory candidates: read the target file first (or confirm it does not exist),',
|
||||
' then write a `.patch` file under the appropriate .inbox/<kind>/ directory using',
|
||||
' the format in MEMORY PATCH FORMAT. Prefer updating existing memory files over',
|
||||
' duplicating facts. Keep patches small and focused.',
|
||||
'9. Write new SKILL.md files or update existing ones in your skills directory.',
|
||||
' Use write_file/edit directly; shell commands are intentionally unavailable in this background flow.',
|
||||
' For skills that live OUTSIDE your skills directory, write a `.patch` file there instead (see UPDATING EXISTING SKILLS).',
|
||||
'10. Write COMPLETE SKILL.md files — never partially update a SKILL.md.',
|
||||
'',
|
||||
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
|
||||
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
|
||||
'should read 0-3 sessions and create 0 skills.',
|
||||
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
|
||||
'should read 0-3 sessions and create few or no artifacts.',
|
||||
'Do not explore the codebase. Work only with the session index, session files, and the memory work directory.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -253,12 +379,20 @@ export const SkillExtractionAgent = (
|
||||
skillsDir: string,
|
||||
sessionIndex: string,
|
||||
existingSkillsSummary: string,
|
||||
memoryDir: string = skillsDir.replace(/[/\\]skills$/, ''),
|
||||
/**
|
||||
* Snapshot of the current memory inbox state, formatted for the agent's
|
||||
* initial context. Lets the agent see what's already pending so it can
|
||||
* extend or rewrite existing canonical patches instead of accumulating
|
||||
* many small ones across sessions. Empty string = nothing pending.
|
||||
*/
|
||||
pendingInboxSummary: string = '',
|
||||
): LocalAgentDefinition<typeof SkillExtractionSchema> => ({
|
||||
kind: 'local',
|
||||
name: 'confucius',
|
||||
displayName: 'Skill Extractor',
|
||||
description:
|
||||
'Extracts reusable skills from past conversation sessions and writes them as SKILL.md files.',
|
||||
'Extracts durable memories and reusable skills from past conversation sessions.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -279,6 +413,8 @@ export const SkillExtractionAgent = (
|
||||
modelConfig: {
|
||||
model: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
memoryInboxAccess: true,
|
||||
autoMemoryExtractionWriteAccess: true,
|
||||
toolConfig: {
|
||||
tools: [
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
@@ -288,7 +424,6 @@ export const SkillExtractionAgent = (
|
||||
LS_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
],
|
||||
},
|
||||
get promptConfig() {
|
||||
@@ -298,6 +433,23 @@ export const SkillExtractionAgent = (
|
||||
contextParts.push(`# Existing Skills\n\n${existingSkillsSummary}`);
|
||||
}
|
||||
|
||||
if (pendingInboxSummary && pendingInboxSummary.trim().length > 0) {
|
||||
contextParts.push(
|
||||
[
|
||||
'# Pending Memory Inbox',
|
||||
'',
|
||||
'The following `.patch` files already exist in the memory inbox',
|
||||
'awaiting user review. If your new findings overlap with one of',
|
||||
'these patches, REWRITE that patch (overwrite the same path) with',
|
||||
'the merged content rather than creating a new patch file. Use the',
|
||||
'canonical filename `extraction.patch` per kind for any new patch',
|
||||
'so the inbox stays consolidated.',
|
||||
'',
|
||||
pendingInboxSummary,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
contextParts.push(
|
||||
[
|
||||
'# Session Index',
|
||||
@@ -326,8 +478,8 @@ export const SkillExtractionAgent = (
|
||||
.replace(/\$\{(\w+)\}/g, '{$1}');
|
||||
|
||||
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.`,
|
||||
systemPrompt: buildSystemPrompt(skillsDir, memoryDir),
|
||||
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 durable memory or repeated workflows using read_file to verify from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. Only write memory if it would clearly help a future session. If recurrence, durability, or future reuse is unclear, create no artifact and explain why. If no skill is justified, create no skill and explain why.`,
|
||||
};
|
||||
},
|
||||
runConfig: {
|
||||
|
||||
@@ -229,6 +229,21 @@ export interface LocalAgentDefinition<
|
||||
*/
|
||||
workspaceDirectories?: string[];
|
||||
|
||||
/**
|
||||
* Allows this agent to access the canonical auto-memory inbox patch files
|
||||
* under `<projectMemoryDir>/.inbox/{private,global}/extraction.patch`.
|
||||
* This is intentionally narrow so the main session cannot bypass review by
|
||||
* writing arbitrary inbox patches.
|
||||
*/
|
||||
memoryInboxAccess?: boolean;
|
||||
|
||||
/**
|
||||
* Restricts write validation for this agent to extracted skill artifacts and
|
||||
* canonical auto-memory inbox patch files. Used by the background
|
||||
* auto-memory extractor so active memory files cannot be edited directly.
|
||||
*/
|
||||
autoMemoryExtractionWriteAccess?: boolean;
|
||||
|
||||
/**
|
||||
* Optional inline MCP servers for this agent.
|
||||
*/
|
||||
@@ -354,3 +369,16 @@ export interface RunConfig {
|
||||
*/
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of an agent reload operation.
|
||||
*/
|
||||
export interface AgentReloadSummary {
|
||||
totalLoaded: number;
|
||||
localCount: number;
|
||||
remoteCount: number;
|
||||
newAgents: string[];
|
||||
updatedAgents: string[];
|
||||
deletedAgents: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
addMemory,
|
||||
applyInboxMemoryPatch,
|
||||
dismissInboxSkill,
|
||||
dismissInboxMemoryPatch,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
listMemoryFiles,
|
||||
@@ -31,6 +34,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getUserSkillsDir: vi.fn(),
|
||||
getGlobalGeminiDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -315,6 +319,619 @@ describe('memory commands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory patch inbox', () => {
|
||||
let tmpDir: string;
|
||||
let memoryTempDir: string;
|
||||
let projectRoot: string;
|
||||
let globalMemoryDir: string;
|
||||
let patchConfig: Config;
|
||||
|
||||
function buildUpdatePatch(
|
||||
absoluteTargetPath: string,
|
||||
original: string,
|
||||
updated: string,
|
||||
): string {
|
||||
// Minimal one-hunk patch that replaces `original` with `updated`.
|
||||
const oldLines = original === '' ? 0 : original.split('\n').length - 1;
|
||||
const newLines = updated === '' ? 0 : updated.split('\n').length - 1;
|
||||
const removed = original
|
||||
.split('\n')
|
||||
.slice(0, oldLines)
|
||||
.map((line) => `-${line}`);
|
||||
const added = updated
|
||||
.split('\n')
|
||||
.slice(0, newLines)
|
||||
.map((line) => `+${line}`);
|
||||
return [
|
||||
`--- ${absoluteTargetPath}`,
|
||||
`+++ ${absoluteTargetPath}`,
|
||||
`@@ -1,${oldLines} +1,${newLines} @@`,
|
||||
...removed,
|
||||
...added,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildCreationPatch(
|
||||
absoluteTargetPath: string,
|
||||
content: string,
|
||||
): string {
|
||||
const contentLines = content.split('\n');
|
||||
const lineCount = content.endsWith('\n')
|
||||
? contentLines.length - 1
|
||||
: contentLines.length;
|
||||
const additions = (
|
||||
content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines
|
||||
).map((line) => `+${line}`);
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${absoluteTargetPath}`,
|
||||
`@@ -0,0 +1,${lineCount} @@`,
|
||||
...additions,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-'));
|
||||
// Canonicalize so test-side paths match production's
|
||||
// canonicalizeDirIfPresent → fs.realpath. On Windows runners
|
||||
// os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but
|
||||
// fs.realpath expands it to the long form (C:\Users\runneradmin\...),
|
||||
// which would otherwise break the auto-pointer absolute-path asserts.
|
||||
tmpDir = await fs.realpath(tmpDir);
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
projectRoot = path.join(tmpDir, 'project');
|
||||
globalMemoryDir = path.join(tmpDir, 'global');
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(globalMemoryDir, { recursive: true });
|
||||
|
||||
patchConfig = {
|
||||
storage: {
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
getProjectMemoryDir: () => memoryTempDir,
|
||||
},
|
||||
isTrustedFolder: () => true,
|
||||
} as unknown as Config;
|
||||
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('aggregates all .patch files of a kind into a single inbox entry', async () => {
|
||||
// Multiple physical .patch files in the kind dir → ONE consolidated
|
||||
// inbox entry per kind, with all hunks merged into entries[].
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-update.patch'),
|
||||
buildUpdatePatch(target, '- old\n', '- new\n'),
|
||||
);
|
||||
// Second source patch — same kind, different hunk.
|
||||
const sibling = path.join(memoryTempDir, 'topic.md');
|
||||
await fs.writeFile(sibling, 'topic A\n');
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-topic.patch'),
|
||||
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
const memoryPatch = patches[0];
|
||||
expect(memoryPatch).toMatchObject({
|
||||
kind: 'private',
|
||||
relativePath: 'private',
|
||||
name: 'Private memory',
|
||||
});
|
||||
// Both source files contributed their hunks.
|
||||
expect(memoryPatch.entries).toHaveLength(2);
|
||||
expect(memoryPatch.sourceFiles).toEqual([
|
||||
'a-update.patch',
|
||||
'b-topic.patch',
|
||||
]);
|
||||
expect(memoryPatch.entries[0].targetPath).toBe(target);
|
||||
expect(memoryPatch.entries[0].isNewFile).toBe(false);
|
||||
expect(memoryPatch.entries[1].targetPath).toBe(sibling);
|
||||
expect(memoryPatch.extractedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits patches whose headers leave the allowed root from the listing', async () => {
|
||||
// Bad patches must NOT show up in the inbox at all — listing filters
|
||||
// them out so the user only ever sees actionable items. (They'd also
|
||||
// be rejected at Apply time, but we don't want to surface them.)
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'escape.patch'),
|
||||
buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
expect(patches).toHaveLength(0);
|
||||
|
||||
// Direct apply still rejects it (defense-in-depth).
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'escape.patch',
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/outside the private memory root/i);
|
||||
});
|
||||
|
||||
it('omits global patches with disallowed targets from the listing', async () => {
|
||||
// Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed.
|
||||
// memory.md (legacy lowercase), sibling .md files, and settings.json all
|
||||
// get filtered out of the listing instead of confusing the user.
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'wrong-name.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'memory.md'),
|
||||
'rejected\n',
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'sibling.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'notes.md'),
|
||||
'rejected\n',
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'settings.patch'),
|
||||
buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies a private update patch and removes it from the inbox', async () => {
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'MEMORY.patch'),
|
||||
buildUpdatePatch(target, '- old\n', '- accepted\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'MEMORY.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('applies a private creation patch with a paired MEMORY.md pointer', async () => {
|
||||
// The auto-memory contract: creating a sibling .md file requires a
|
||||
// hunk that adds a pointer to MEMORY.md (so the sibling becomes
|
||||
// discoverable to future sessions).
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '# Project Memory\n');
|
||||
|
||||
const target = path.join(memoryTempDir, 'topic.md');
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
const multiHunkPatch =
|
||||
buildCreationPatch(target, '# Topic\n- new fact\n') +
|
||||
buildUpdatePatch(
|
||||
memoryMd,
|
||||
'# Project Memory\n',
|
||||
'# Project Memory\n- See topic.md for the new fact.\n',
|
||||
);
|
||||
await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Topic\n- new fact\n',
|
||||
);
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain(
|
||||
'See topic.md',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => {
|
||||
// Sibling .md files in <memoryDir> are loaded by future sessions ONLY
|
||||
// when MEMORY.md references them. To avoid orphans, applying a sibling
|
||||
// creation patch with no MEMORY.md update auto-bundles a pointer line.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '# Project Memory\n');
|
||||
|
||||
const target = path.join(memoryTempDir, 'orphan-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'orphan-topic.patch'),
|
||||
buildCreationPatch(target, '# Orphan Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'orphan-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
|
||||
expect(result.message).toContain('"orphan-topic.md"');
|
||||
// The sibling exists.
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Orphan Topic\n',
|
||||
);
|
||||
// MEMORY.md now references the sibling — using ABSOLUTE PATH so a
|
||||
// future agent can `read_file` it without resolving relatives. We
|
||||
// assert the line shape is `- See <absolute>/orphan-topic.md ...` and
|
||||
// verify the path is absolute via path.isAbsolute (cross-platform —
|
||||
// the previous /^- See \/.+\/.../ regex was Unix-only and broke on
|
||||
// Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`).
|
||||
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
|
||||
expect(memoryAfter).toContain(target);
|
||||
const pointerLineMatch = memoryAfter.match(
|
||||
/^- See (.+orphan-topic\.md) /m,
|
||||
);
|
||||
expect(pointerLineMatch).not.toBeNull();
|
||||
expect(path.isAbsolute(pointerLineMatch![1])).toBe(true);
|
||||
// The patch was committed and removed from inbox.
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'orphan-topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => {
|
||||
// No MEMORY.md on disk + a creation patch for a sibling →
|
||||
// auto-bundle should create MEMORY.md from scratch with the pointer.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await expect(fs.access(memoryMd)).rejects.toThrow();
|
||||
|
||||
const target = path.join(memoryTempDir, 'fresh-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'fresh-topic.patch'),
|
||||
buildCreationPatch(target, '# Fresh Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'fresh-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
|
||||
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
|
||||
expect(memoryAfter).toContain('Project Memory');
|
||||
// Pointer must be absolute so the future agent can read_file directly.
|
||||
expect(memoryAfter).toContain(target);
|
||||
});
|
||||
|
||||
it('accepts a private creation patch when MEMORY.md already references the new file', async () => {
|
||||
// If MEMORY.md was previously prepared with a pointer (e.g. by a
|
||||
// separately-applied patch), the follow-up creation patch is fine.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(
|
||||
memoryMd,
|
||||
'# Project Memory\n- See later-topic.md for details.\n',
|
||||
);
|
||||
|
||||
const target = path.join(memoryTempDir, 'later-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'later-topic.patch'),
|
||||
buildCreationPatch(target, '# Later Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'later-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Later Topic\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => {
|
||||
const target = path.join(globalMemoryDir, 'GEMINI.md');
|
||||
// Sanity check: target does not exist before apply.
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildCreationPatch(target, '# Personal preferences\n- prefer X\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Personal preferences\n- prefer X\n',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('applies a global update patch to ~/.gemini/GEMINI.md', async () => {
|
||||
const target = path.join(globalMemoryDir, 'GEMINI.md');
|
||||
await fs.writeFile(target, '- prefer X\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'GEMINI.md'),
|
||||
'Prefer concise.\n',
|
||||
),
|
||||
);
|
||||
|
||||
const result = await dismissInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('apply with relativePath = kind runs every source patch in sequence', async () => {
|
||||
// Aggregate apply: pass `relativePath = kind`. Each .patch file under
|
||||
// the kind dir is applied atomically in lexical order; the result
|
||||
// message summarizes successes/failures.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '- old\n');
|
||||
const sibling = path.join(memoryTempDir, 'topic.md');
|
||||
await fs.writeFile(sibling, 'topic A\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-update.patch'),
|
||||
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-topic.patch'),
|
||||
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private', // ← aggregate mode
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/applied all 2 private memory patches/i);
|
||||
|
||||
// Both targets were updated, both source patches removed.
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
|
||||
await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'a-update.patch')),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'b-topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('aggregate apply reports successes and failures when one source patch is stale', async () => {
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
// Good patch: updates the existing line.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-good.patch'),
|
||||
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
|
||||
);
|
||||
// Stale patch: context expects something that doesn't exist.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-stale.patch'),
|
||||
buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
|
||||
// Any failure → success=false so the dialog keeps the inbox entry
|
||||
// visible. (The successful sub-patches were already removed from disk;
|
||||
// the next listing will surface only the failures for retry.)
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/applied 1 of 2/i);
|
||||
expect(result.message).toMatch(/b-stale\.patch/);
|
||||
|
||||
// Good patch committed and removed; stale patch stays in inbox.
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'a-good.patch')),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'b-stale.patch')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('dismiss with relativePath = kind removes all source patches', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a.patch'),
|
||||
buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b.patch'),
|
||||
buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'),
|
||||
);
|
||||
|
||||
const result = await dismissInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/dismissed 2/i);
|
||||
await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
|
||||
// memory.md (lowercase) is NOT a valid global memory file.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'wrong-name.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'memory.md'),
|
||||
'Should be rejected.\n',
|
||||
),
|
||||
);
|
||||
|
||||
// Sibling .md files in ~/.gemini/ are also not allowed.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'sibling.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'notes.md'),
|
||||
'Should be rejected.\n',
|
||||
),
|
||||
);
|
||||
|
||||
// Non-memory files (settings, credentials) must stay off-limits.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'settings.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'settings.json'),
|
||||
'{"foo": 1}\n',
|
||||
),
|
||||
);
|
||||
|
||||
for (const fileName of [
|
||||
'wrong-name.patch',
|
||||
'sibling.patch',
|
||||
'settings.patch',
|
||||
]) {
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
fileName,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/outside the global memory root/i);
|
||||
}
|
||||
|
||||
// None of the bogus targets were created.
|
||||
for (const orphan of ['memory.md', 'notes.md', 'settings.json']) {
|
||||
await expect(
|
||||
fs.access(path.join(globalMemoryDir, orphan)),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid memory patch paths', async () => {
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'../MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid memory patch path.');
|
||||
});
|
||||
|
||||
it('rejects a creation patch whose target already exists', async () => {
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, 'pre-existing\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'MEMORY.patch'),
|
||||
buildCreationPatch(target, 'replacement\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/declares a new file/);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'pre-existing\n',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'MEMORY.patch')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveInboxSkill', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
|
||||
@@ -13,11 +13,15 @@ import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { flattenMemory } from '../config/memory.js';
|
||||
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
|
||||
import { getGlobalMemoryFilePath } from '../tools/memoryTool.js';
|
||||
import {
|
||||
type AppliedSkillPatchTarget,
|
||||
applyParsedPatchesWithAllowedRoots,
|
||||
applyParsedSkillPatches,
|
||||
canonicalizeAllowedPatchRoots,
|
||||
hasParsedPatchHunks,
|
||||
isProjectSkillPatchTarget,
|
||||
resolveTargetWithinAllowedRoots,
|
||||
validateParsedSkillPatchHeaders,
|
||||
} from '../services/memoryPatchUtils.js';
|
||||
import { readExtractionState } from '../services/memoryService.js';
|
||||
@@ -338,6 +342,46 @@ export interface InboxPatch {
|
||||
extractedAt?: string;
|
||||
}
|
||||
|
||||
export type InboxMemoryPatchKind = 'private' | 'global';
|
||||
|
||||
/**
|
||||
* One target file inside a memory patch (most patches will have a single entry).
|
||||
*/
|
||||
export interface InboxMemoryPatchEntry {
|
||||
/** Absolute path of the markdown file the patch will modify. */
|
||||
targetPath: string;
|
||||
/** Unified diff for this single file (used for UI preview). */
|
||||
diffContent: string;
|
||||
/** True when this entry creates a new file (`/dev/null` source). */
|
||||
isNewFile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the AGGREGATED inbox state for one memory kind. Even when the
|
||||
* extraction agent has produced multiple `.patch` files under
|
||||
* `<memoryDir>/.inbox/<kind>/` (e.g. across several sessions), the inbox
|
||||
* surfaces them as ONE entry per kind. Apply runs each underlying patch in
|
||||
* sequence; Dismiss removes them all.
|
||||
*/
|
||||
export interface InboxMemoryPatch {
|
||||
/** Memory tier — one entry per kind in the inbox. */
|
||||
kind: InboxMemoryPatchKind;
|
||||
/**
|
||||
* Stable identifier for this consolidated entry. Set to the kind itself
|
||||
* (`"private"` or `"global"`); kept in the type for backwards-compat with
|
||||
* the per-file API the dialog passes through.
|
||||
*/
|
||||
relativePath: string;
|
||||
/** Display name shown in the inbox row (e.g. `"Private memory"`). */
|
||||
name: string;
|
||||
/** All hunks from all underlying source patches, concatenated in order. */
|
||||
entries: InboxMemoryPatchEntry[];
|
||||
/** Basenames of the underlying `.patch` files being aggregated. */
|
||||
sourceFiles: string[];
|
||||
/** Most recent mtime across the source files (ISO string), if known. */
|
||||
extractedAt?: string;
|
||||
}
|
||||
|
||||
interface StagedInboxPatchTarget {
|
||||
targetPath: string;
|
||||
tempPath: string;
|
||||
@@ -372,6 +416,97 @@ function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getMemoryPatchRoot(
|
||||
memoryDir: string,
|
||||
kind: InboxMemoryPatchKind,
|
||||
): string {
|
||||
return path.join(memoryDir, '.inbox', kind);
|
||||
}
|
||||
|
||||
function isSubpathOrSame(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = path.relative(parentPath, childPath);
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInboxMemoryPatchPath(
|
||||
relativePath: string,
|
||||
): string | undefined {
|
||||
if (
|
||||
relativePath.length === 0 ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath.includes('\\')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedPath = path.posix.normalize(relativePath);
|
||||
if (
|
||||
normalizedPath === '.' ||
|
||||
normalizedPath.startsWith('../') ||
|
||||
normalizedPath === '..' ||
|
||||
!normalizedPath.endsWith('.patch')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory roots (or single-file allowlists) that a memory patch
|
||||
* of the given kind is allowed to modify. Memory patch headers must reference
|
||||
* paths inside / equal to one of these entries after canonical resolution.
|
||||
*
|
||||
* - `private` allows any markdown file inside the project memory directory.
|
||||
* - `global` is intentionally a single-file allowlist: the only writeable
|
||||
* global file is the personal `~/.gemini/GEMINI.md`. Other files under
|
||||
* `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits.
|
||||
*/
|
||||
export function getAllowedMemoryPatchRoots(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
): string[] {
|
||||
switch (kind) {
|
||||
case 'private':
|
||||
return [path.resolve(config.storage.getProjectMemoryTempDir())];
|
||||
case 'global':
|
||||
return [path.resolve(getGlobalMemoryFilePath())];
|
||||
default:
|
||||
throw new Error(`Unknown memory patch kind: ${kind as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return stats.mtime.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getInboxMemoryPatchSourcePath(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
relativePath: string,
|
||||
): Promise<string | undefined> {
|
||||
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
|
||||
if (!normalizedPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const patchRoot = path.resolve(
|
||||
getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind),
|
||||
);
|
||||
const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/'));
|
||||
if (!isSubpathOrSame(sourcePath, patchRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
targetPaths: string[],
|
||||
config: Config,
|
||||
@@ -395,6 +530,670 @@ async function getPatchExtractedAt(
|
||||
}
|
||||
}
|
||||
|
||||
function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
|
||||
switch (kind) {
|
||||
case 'private':
|
||||
return 'Private memory';
|
||||
case 'global':
|
||||
return 'Global memory';
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute paths of every `.patch` file currently in the kind's
|
||||
* inbox directory (sorted by basename for stable ordering at apply time).
|
||||
*
|
||||
* NOTE: this is a raw filesystem listing — it does NOT validate patch shape
|
||||
* or that targets fall inside the kind's allowed root. Callers that need
|
||||
* "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`.
|
||||
*/
|
||||
async function listInboxPatchFiles(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
): Promise<string[]> {
|
||||
const patchRoot = getMemoryPatchRoot(
|
||||
config.storage.getProjectMemoryTempDir(),
|
||||
kind,
|
||||
);
|
||||
const found: string[] = [];
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
let dirEntries: Array<import('node:fs').Dirent>;
|
||||
try {
|
||||
dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith('.patch')) {
|
||||
found.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(patchRoot);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the inbox patch files that pass the same validation as the
|
||||
* inbox listing (parseable, has hunks, valid headers, targets in the
|
||||
* kind's allowed root). Used by aggregate apply so the user only ever sees
|
||||
* results for patches the inbox actually surfaced.
|
||||
*/
|
||||
async function listValidInboxPatchFiles(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
): Promise<string[]> {
|
||||
const patchFiles = await listInboxPatchFiles(config, kind);
|
||||
if (patchFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allowedRoots = await canonicalizeAllowedPatchRoots(
|
||||
getAllowedMemoryPatchRoots(config, kind),
|
||||
);
|
||||
|
||||
const valid: string[] = [];
|
||||
for (const sourcePath of patchFiles) {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(sourcePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: Diff.StructuredPatch[];
|
||||
try {
|
||||
parsed = Diff.parsePatch(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!hasParsedPatchHunks(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validated = validateParsedSkillPatchHeaders(parsed);
|
||||
if (!validated.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetsAllAllowed = await Promise.all(
|
||||
validated.patches.map(
|
||||
async (header) =>
|
||||
(await resolveTargetWithinAllowedRoots(
|
||||
header.targetPath,
|
||||
allowedRoots,
|
||||
)) !== undefined,
|
||||
),
|
||||
);
|
||||
if (!targetsAllAllowed.every(Boolean)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
valid.push(sourcePath);
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans `<memoryDir>/.inbox/{private,global}/` and returns ONE consolidated
|
||||
* inbox entry per kind. Each entry aggregates all hunks from every valid
|
||||
* underlying `.patch` file. Patches that fail validation (unparseable, no
|
||||
* hunks, target outside allowed root) are silently skipped so they don't
|
||||
* pollute the inbox UI.
|
||||
*/
|
||||
export async function listInboxMemoryPatches(
|
||||
config: Config,
|
||||
): Promise<InboxMemoryPatch[]> {
|
||||
const kinds: InboxMemoryPatchKind[] = ['private', 'global'];
|
||||
const aggregated: InboxMemoryPatch[] = [];
|
||||
|
||||
for (const kind of kinds) {
|
||||
const allowedRoots = await canonicalizeAllowedPatchRoots(
|
||||
getAllowedMemoryPatchRoots(config, kind),
|
||||
);
|
||||
const patchFiles = await listInboxPatchFiles(config, kind);
|
||||
|
||||
const aggregatedEntries: InboxMemoryPatchEntry[] = [];
|
||||
const sourceFiles: string[] = [];
|
||||
let latestMtime: string | undefined;
|
||||
|
||||
for (const sourcePath of patchFiles) {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(sourcePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: Diff.StructuredPatch[];
|
||||
try {
|
||||
parsed = Diff.parsePatch(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!hasParsedPatchHunks(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validated = validateParsedSkillPatchHeaders(parsed);
|
||||
if (!validated.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip the entire source file if ANY of its targets escapes the kind's
|
||||
// allowed root.
|
||||
const targetsAllAllowed = await Promise.all(
|
||||
validated.patches.map(
|
||||
async (header) =>
|
||||
(await resolveTargetWithinAllowedRoots(
|
||||
header.targetPath,
|
||||
allowedRoots,
|
||||
)) !== undefined,
|
||||
),
|
||||
);
|
||||
if (!targetsAllAllowed.every(Boolean)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [index, header] of validated.patches.entries()) {
|
||||
aggregatedEntries.push({
|
||||
targetPath: header.targetPath,
|
||||
isNewFile: header.isNewFile,
|
||||
diffContent: formatParsedDiff(parsed[index]),
|
||||
});
|
||||
}
|
||||
|
||||
sourceFiles.push(path.basename(sourcePath));
|
||||
|
||||
const mtime = await getFileMtimeIso(sourcePath);
|
||||
if (mtime && (!latestMtime || mtime > latestMtime)) {
|
||||
latestMtime = mtime;
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
aggregated.push({
|
||||
kind,
|
||||
relativePath: kind,
|
||||
name: formatMemoryKindLabel(kind),
|
||||
entries: aggregatedEntries,
|
||||
sourceFiles,
|
||||
extractedAt: latestMtime,
|
||||
});
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an inbox memory patch atomically and removes the patch on success.
|
||||
*
|
||||
* Process:
|
||||
* 1. Parse + validate the patch headers (absolute paths only, no `a/`/`b/`).
|
||||
* 2. Dry-run the patch against the current target content (or empty for
|
||||
* `/dev/null` creation patches).
|
||||
* 3. Stage the patched content to a temp file, then rename into place.
|
||||
* 4. On any failure, restore previous content from the staged snapshot and
|
||||
* leave the inbox patch intact for retry.
|
||||
*/
|
||||
/**
|
||||
* Applies one inbox memory entry. Two modes:
|
||||
* - Aggregate mode (`relativePath === kind`): walk every `.patch` file in
|
||||
* the kind's inbox directory and apply each one in lexical order. Each
|
||||
* file is its own atomic transaction; failures don't block subsequent
|
||||
* successes. Returns an aggregated summary (e.g. "Applied 3 of 4 sub-
|
||||
* patches; 1 failed: …").
|
||||
* - Single-file mode (legacy): `relativePath` points at a specific
|
||||
* `.patch` filename. Used by tests and direct callers.
|
||||
*/
|
||||
export async function applyInboxMemoryPatch(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
relativePath: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (relativePath === kind) {
|
||||
return applyAllInboxPatchesForKind(config, kind);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
|
||||
if (!normalizedPath) {
|
||||
return { success: false, message: 'Invalid memory patch path.' };
|
||||
}
|
||||
|
||||
const sourcePath = await getInboxMemoryPatchSourcePath(
|
||||
config,
|
||||
kind,
|
||||
normalizedPath,
|
||||
);
|
||||
if (!sourcePath) {
|
||||
return { success: false, message: 'Invalid memory patch path.' };
|
||||
}
|
||||
|
||||
return applyMemoryPatchFile(config, kind, sourcePath, normalizedPath);
|
||||
}
|
||||
|
||||
async function applyAllInboxPatchesForKind(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Only attempt patches the user actually saw in the inbox listing.
|
||||
// Files that were filtered (bad headers, escape allowed root, etc.) stay
|
||||
// on disk untouched.
|
||||
const patchFiles = await listValidInboxPatchFiles(config, kind);
|
||||
if (patchFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `No ${kind} memory patches in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
const successes: string[] = [];
|
||||
const failures: Array<{ name: string; reason: string }> = [];
|
||||
let pointersAddedAcrossPatches: string[] = [];
|
||||
|
||||
for (const sourcePath of patchFiles) {
|
||||
const basename = path.basename(sourcePath);
|
||||
const result = await applyMemoryPatchFile(
|
||||
config,
|
||||
kind,
|
||||
sourcePath,
|
||||
basename,
|
||||
);
|
||||
if (result.success) {
|
||||
successes.push(basename);
|
||||
// Surface auto-added MEMORY.md pointer info if present.
|
||||
const pointerMatch = result.message.match(
|
||||
/Auto-added MEMORY\.md pointer for ([^.]+)\./,
|
||||
);
|
||||
if (pointerMatch) {
|
||||
pointersAddedAcrossPatches.push(pointerMatch[1]);
|
||||
}
|
||||
} else {
|
||||
failures.push({ name: basename, reason: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
// De-dup pointer notes (same sibling could have been mentioned twice).
|
||||
pointersAddedAcrossPatches = Array.from(new Set(pointersAddedAcrossPatches));
|
||||
|
||||
const total = successes.length + failures.length;
|
||||
if (failures.length === 0) {
|
||||
const pointerNote =
|
||||
pointersAddedAcrossPatches.length > 0
|
||||
? ` Auto-added MEMORY.md pointer(s) for ${pointersAddedAcrossPatches.join('; ')}.`
|
||||
: '';
|
||||
return {
|
||||
success: true,
|
||||
message: `Applied all ${successes.length} ${kind} memory patch${
|
||||
successes.length === 1 ? '' : 'es'
|
||||
}.${pointerNote}`,
|
||||
};
|
||||
}
|
||||
|
||||
const failureSummary = failures
|
||||
.map((f) => `"${f.name}" — ${f.reason}`)
|
||||
.join('; ');
|
||||
// Any failure → success=false so the dialog keeps the inbox entry visible
|
||||
// (the user needs to see and retry/dismiss the remaining sub-patches).
|
||||
// The successful sub-patches have already been removed from disk by
|
||||
// applyMemoryPatchFile, so the next listing will show only the failures.
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
`Applied ${successes.length} of ${total} ${kind} memory patches. ` +
|
||||
`${failures.length} failed: ${failureSummary}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function canonicalizeDirIfPresent(dirPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(dirPath);
|
||||
} catch {
|
||||
return path.resolve(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the basenames of any sibling .md files (not MEMORY.md itself) that
|
||||
* are being CREATED by this patch under `<memoryDir>/` directly.
|
||||
*/
|
||||
function findSiblingCreations(
|
||||
appliedResults: readonly AppliedSkillPatchTarget[],
|
||||
memoryDir: string,
|
||||
): AppliedSkillPatchTarget[] {
|
||||
return appliedResults.filter((entry) => {
|
||||
if (!entry.isNewFile) return false;
|
||||
const targetDir = path.dirname(path.resolve(entry.targetPath));
|
||||
if (targetDir !== memoryDir) return false;
|
||||
const basename = path.basename(entry.targetPath);
|
||||
if (basename.toLowerCase() === 'memory.md') return false;
|
||||
return basename.toLowerCase().endsWith('.md');
|
||||
});
|
||||
}
|
||||
|
||||
interface AutoPointerAugmentation {
|
||||
/** Patch results, possibly with a synthesized/extended MEMORY.md entry. */
|
||||
results: AppliedSkillPatchTarget[];
|
||||
/** Sibling basenames a pointer was auto-added for (empty if none). */
|
||||
pointersAdded: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MEMORY.md is the index that gets injected into future agent contexts.
|
||||
* Sibling .md files in `<memoryDir>/` are loaded ON DEMAND by the runtime
|
||||
* agent via `read_file` — but only IF MEMORY.md references them by name
|
||||
* (see `getUserProjectMemoryPaths`).
|
||||
*
|
||||
* If a private patch creates a sibling without also referencing it from
|
||||
* MEMORY.md, the new file would never be discoverable. Rather than rejecting
|
||||
* the patch (bad UX), we auto-bundle a MEMORY.md update that adds a
|
||||
* one-line pointer per orphan sibling. The augmented entry is then committed
|
||||
* atomically alongside the rest of the patch.
|
||||
*
|
||||
* If the patch already updates/creates MEMORY.md and the new content already
|
||||
* references the sibling, no augmentation is needed.
|
||||
*/
|
||||
async function augmentWithAutoPointers(
|
||||
config: Config,
|
||||
appliedResults: readonly AppliedSkillPatchTarget[],
|
||||
): Promise<AutoPointerAugmentation> {
|
||||
const memoryDir = await canonicalizeDirIfPresent(
|
||||
config.storage.getProjectMemoryTempDir(),
|
||||
);
|
||||
const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
|
||||
|
||||
const siblingCreations = findSiblingCreations(appliedResults, memoryDir);
|
||||
if (siblingCreations.length === 0) {
|
||||
return { results: [...appliedResults], pointersAdded: [] };
|
||||
}
|
||||
|
||||
// Locate (or initialize) the MEMORY.md entry we'll mutate.
|
||||
const existingIdx = appliedResults.findIndex(
|
||||
(entry) => path.resolve(entry.targetPath) === memoryMdPath,
|
||||
);
|
||||
let memoryEntry: AppliedSkillPatchTarget;
|
||||
if (existingIdx >= 0) {
|
||||
memoryEntry = { ...appliedResults[existingIdx] };
|
||||
} else {
|
||||
let originalContent = '';
|
||||
let isNewFile = true;
|
||||
try {
|
||||
originalContent = await fs.readFile(memoryMdPath, 'utf-8');
|
||||
isNewFile = false;
|
||||
} catch {
|
||||
// MEMORY.md doesn't exist yet — we'll create it with a default heading.
|
||||
}
|
||||
memoryEntry = {
|
||||
targetPath: memoryMdPath,
|
||||
original: originalContent,
|
||||
patched: isNewFile ? '# Project Memory\n' : originalContent,
|
||||
isNewFile,
|
||||
};
|
||||
}
|
||||
|
||||
const pointersAdded: string[] = [];
|
||||
for (const sibling of siblingCreations) {
|
||||
const basename = path.basename(sibling.targetPath);
|
||||
// Resolve to absolute path so the runtime agent can `read_file` the
|
||||
// sibling directly without needing to know <memoryDir>.
|
||||
const absoluteTarget = path.resolve(sibling.targetPath);
|
||||
// Existing reference can be by either basename or absolute path; both count.
|
||||
if (
|
||||
memoryEntry.patched.includes(basename) ||
|
||||
memoryEntry.patched.includes(absoluteTarget)
|
||||
) {
|
||||
continue; // Already referenced.
|
||||
}
|
||||
const stem = basename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').trim();
|
||||
const pointer = `- See ${absoluteTarget} for ${stem || basename} notes.`;
|
||||
memoryEntry.patched = memoryEntry.patched.endsWith('\n')
|
||||
? `${memoryEntry.patched}${pointer}\n`
|
||||
: `${memoryEntry.patched}\n${pointer}\n`;
|
||||
pointersAdded.push(basename);
|
||||
}
|
||||
|
||||
if (pointersAdded.length === 0) {
|
||||
return { results: [...appliedResults], pointersAdded: [] };
|
||||
}
|
||||
|
||||
const results = [...appliedResults];
|
||||
if (existingIdx >= 0) {
|
||||
results[existingIdx] = memoryEntry;
|
||||
} else {
|
||||
results.push(memoryEntry);
|
||||
}
|
||||
return { results, pointersAdded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: parses, validates, and atomically commits a memory patch
|
||||
* file at a known absolute path. Separated from `applyInboxMemoryPatch` so the
|
||||
* path-resolution and patch-apply concerns stay testable independently.
|
||||
*/
|
||||
async function applyMemoryPatchFile(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
patchPath: string,
|
||||
displayName: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(patchPath, 'utf-8');
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Diff.StructuredPatch[];
|
||||
try {
|
||||
parsed = Diff.parsePatch(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to parse memory patch "${displayName}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
if (!hasParsedPatchHunks(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" contains no valid hunks.`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowedRoots = await canonicalizeAllowedPatchRoots(
|
||||
getAllowedMemoryPatchRoots(config, kind),
|
||||
);
|
||||
const applied = await applyParsedPatchesWithAllowedRoots(
|
||||
parsed,
|
||||
allowedRoots,
|
||||
);
|
||||
if (!applied.success) {
|
||||
switch (applied.reason) {
|
||||
case 'missingTargetPath':
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" is missing a target file path.`,
|
||||
};
|
||||
case 'invalidPatchHeaders':
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" has invalid diff headers.`,
|
||||
};
|
||||
case 'outsideAllowedRoots':
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root: ${applied.targetPath}`,
|
||||
};
|
||||
case 'newFileAlreadyExists':
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" declares a new file, but the target already exists: ${applied.targetPath}`,
|
||||
};
|
||||
case 'targetNotFound':
|
||||
return {
|
||||
success: false,
|
||||
message: `Target file not found: ${applied.targetPath}`,
|
||||
};
|
||||
case 'doesNotApply':
|
||||
return {
|
||||
success: false,
|
||||
message: applied.isNewFile
|
||||
? `Memory patch "${displayName}" failed to apply for new file ${applied.targetPath}.`
|
||||
: `Memory patch does not apply cleanly to ${applied.targetPath}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" could not be applied.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-bundle a MEMORY.md pointer for any sibling .md the patch creates
|
||||
// without referencing it from MEMORY.md. Without that pointer the new file
|
||||
// would never be loaded into a future session (see augmentWithAutoPointers).
|
||||
let pointersAdded: string[] = [];
|
||||
let resultsToCommit: AppliedSkillPatchTarget[] = [...applied.results];
|
||||
if (kind === 'private') {
|
||||
const augmented = await augmentWithAutoPointers(config, applied.results);
|
||||
resultsToCommit = augmented.results;
|
||||
pointersAdded = augmented.pointersAdded;
|
||||
}
|
||||
|
||||
let stagedTargets: StagedInboxPatchTarget[];
|
||||
try {
|
||||
stagedTargets = await stageInboxPatchTargets(resultsToCommit);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" could not be staged: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const committedTargets: StagedInboxPatchTarget[] = [];
|
||||
try {
|
||||
for (const stagedTarget of stagedTargets) {
|
||||
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
|
||||
committedTargets.push(stagedTarget);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const committedTarget of committedTargets.reverse()) {
|
||||
try {
|
||||
await restoreCommittedInboxPatchTarget(committedTarget);
|
||||
} catch {
|
||||
// Best-effort rollback. We still report the commit failure below.
|
||||
}
|
||||
}
|
||||
await cleanupStagedInboxPatchTargets(
|
||||
stagedTargets.filter((target) => !committedTargets.includes(target)),
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${displayName}" could not be applied atomically: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.unlink(patchPath);
|
||||
|
||||
const fileCount = resultsToCommit.length;
|
||||
const baseMessage = `Applied memory patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`;
|
||||
const pointerNote =
|
||||
pointersAdded.length > 0
|
||||
? ` Auto-added MEMORY.md pointer for ${pointersAdded
|
||||
.map((name) => `"${name}"`)
|
||||
.join(', ')} so the new sibling file is discoverable.`
|
||||
: '';
|
||||
return {
|
||||
success: true,
|
||||
message: `${baseMessage}${pointerNote}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes inbox memory patch(es) without applying. Two modes:
|
||||
* - Aggregate (`relativePath === kind`): unlink every `.patch` file in the
|
||||
* kind's inbox directory. Used by the consolidated inbox UI's Dismiss.
|
||||
* - Single-file (legacy): unlink one specific `.patch` file.
|
||||
*/
|
||||
export async function dismissInboxMemoryPatch(
|
||||
config: Config,
|
||||
kind: InboxMemoryPatchKind,
|
||||
relativePath: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (relativePath === kind) {
|
||||
// Dismiss the same set of files the listing surfaced — leave the
|
||||
// already-filtered (bad-target, malformed) files alone for forensic
|
||||
// inspection.
|
||||
const patchFiles = await listValidInboxPatchFiles(config, kind);
|
||||
if (patchFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `No ${kind} memory patches in inbox.`,
|
||||
};
|
||||
}
|
||||
let removed = 0;
|
||||
for (const sourcePath of patchFiles) {
|
||||
try {
|
||||
await fs.unlink(sourcePath);
|
||||
removed += 1;
|
||||
} catch {
|
||||
// Best-effort: keep going if one delete fails.
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: removed > 0,
|
||||
message: `Dismissed ${removed} ${kind} memory patch${
|
||||
removed === 1 ? '' : 'es'
|
||||
} from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
|
||||
if (!normalizedPath) {
|
||||
return { success: false, message: 'Invalid memory patch path.' };
|
||||
}
|
||||
|
||||
const sourcePath = await getInboxMemoryPatchSourcePath(
|
||||
config,
|
||||
kind,
|
||||
normalizedPath,
|
||||
);
|
||||
if (!sourcePath) {
|
||||
return { success: false, message: 'Invalid memory patch path.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(sourcePath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Memory patch "${normalizedPath}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.unlink(sourcePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Dismissed "${normalizedPath}" from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function findNearestExistingDirectory(
|
||||
startPath: string,
|
||||
): Promise<string> {
|
||||
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
} from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
import {
|
||||
runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
runWithScopedMemoryInboxAccess,
|
||||
} from './scoped-config.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -3656,6 +3660,168 @@ describe('Config JIT Initialization', () => {
|
||||
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow isPathAllowed to write into the auto-memory inbox', () => {
|
||||
// <projectMemoryDir>/.inbox/ is owned by the extraction agent and the
|
||||
// /memory inbox review flow. The main agent must not be able to drop
|
||||
// patches in there directly, even though it falls inside <projectTempDir>.
|
||||
// We bypass Config.initialize() (the GitService init path is independently
|
||||
// flaky in this suite) by spying on the storage methods isPathAllowed
|
||||
// actually consults.
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
|
||||
// The inbox directory itself and any path under it are denied.
|
||||
expect(config.isPathAllowed(inboxRoot)).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'private', 'foo.patch')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'global', 'bar.patch')),
|
||||
).toBe(false);
|
||||
|
||||
// Sibling files under <projectMemoryDir> stay reachable so the main
|
||||
// agent can edit MEMORY.md and topic notes directly.
|
||||
expect(
|
||||
config.isPathAllowed(path.join(fakeMemoryTempDir, 'MEMORY.md')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(fakeMemoryTempDir, 'some-topic.md')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow scoped extraction access only to canonical inbox patches', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
const privateExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'extraction.patch',
|
||||
);
|
||||
const globalExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'global',
|
||||
'extraction.patch',
|
||||
);
|
||||
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
|
||||
|
||||
runWithScopedMemoryInboxAccess(() => {
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
|
||||
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
|
||||
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(
|
||||
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
|
||||
});
|
||||
|
||||
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
const fakeSkillsMemoryDir = path.join(fakeMemoryTempDir, 'skills');
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectSkillsMemoryDir').mockReturnValue(
|
||||
fakeSkillsMemoryDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
const privateExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'extraction.patch',
|
||||
);
|
||||
const skillArtifact = path.join(
|
||||
fakeSkillsMemoryDir,
|
||||
'my-skill',
|
||||
'SKILL.md',
|
||||
);
|
||||
const activeMemoryPath = path.join(fakeMemoryTempDir, 'MEMORY.md');
|
||||
const projectTempPath = path.join(fakeProjectTempDir, 'logs', 'run.log');
|
||||
const workspaceMemoryPath = path.join('/tmp/test', 'GEMINI.md');
|
||||
|
||||
expect(config.validatePathAccess(activeMemoryPath)).toBeNull();
|
||||
|
||||
runWithScopedAutoMemoryExtractionWriteAccess(() => {
|
||||
expect(config.validatePathAccess(skillArtifact)).toBeNull();
|
||||
expect(config.validatePathAccess(activeMemoryPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
expect(config.validatePathAccess(projectTempPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
expect(config.validatePathAccess(workspaceMemoryPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
|
||||
// Reads still use the normal workspace/temp allowlists.
|
||||
expect(config.validatePathAccess(activeMemoryPath, 'read')).toBeNull();
|
||||
});
|
||||
|
||||
runWithScopedMemoryInboxAccess(() => {
|
||||
runWithScopedAutoMemoryExtractionWriteAccess(() => {
|
||||
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAutoMemoryEnabled', () => {
|
||||
|
||||
@@ -140,7 +140,11 @@ import type { GenerateContentParameters } from '@google/genai';
|
||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
import { getWorkspaceContextOverride } from './scoped-config.js';
|
||||
import {
|
||||
getWorkspaceContextOverride,
|
||||
hasScopedAutoMemoryExtractionWriteAccess,
|
||||
hasScopedMemoryInboxAccess,
|
||||
} from './scoped-config.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
@@ -3063,6 +3067,52 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.ideMode = value;
|
||||
}
|
||||
|
||||
private isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath: string,
|
||||
resolvedPath: string,
|
||||
inboxRoot: string,
|
||||
): boolean {
|
||||
if (!hasScopedMemoryInboxAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const isCanonicalPatchPath = (['private', 'global'] as const).some(
|
||||
(kind) =>
|
||||
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
|
||||
);
|
||||
if (!isCanonicalPatchPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedMemoryRoot = resolveToRealPath(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
);
|
||||
return isSubpath(resolvedMemoryRoot, resolvedPath);
|
||||
}
|
||||
|
||||
private isScopedAutoMemoryExtractionWritePathAllowed(
|
||||
absolutePath: string,
|
||||
resolvedPath: string,
|
||||
): boolean {
|
||||
if (!hasScopedAutoMemoryExtractionWriteAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedSkillsMemoryDir = resolveToRealPath(
|
||||
this.storage.getProjectSkillsMemoryDir(),
|
||||
);
|
||||
if (isSubpath(resolvedSkillsMemoryDir, resolvedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
path.join(this.storage.getProjectMemoryTempDir(), '.inbox'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
@@ -3077,12 +3127,48 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* file (the latter is the only file under `~/.gemini/` that is reachable —
|
||||
* settings, credentials, keybindings, etc. remain disallowed).
|
||||
*
|
||||
* One subtree is *carved back out*: `<projectMemoryDir>/.inbox/` is owned by
|
||||
* the auto-memory extraction agent and the `/memory inbox` review flow. The
|
||||
* main agent is denied access to it even though it falls inside the project
|
||||
* temp dir; the extraction agent receives a narrow execution-scoped exception
|
||||
* for `.inbox/{private,global}/extraction.patch`.
|
||||
*
|
||||
* @param absolutePath The absolute path to check.
|
||||
* @returns true if the path is allowed, false otherwise.
|
||||
*/
|
||||
isPathAllowed(absolutePath: string): boolean {
|
||||
const resolvedPath = resolveToRealPath(absolutePath);
|
||||
|
||||
// The auto-memory inbox (`<projectMemoryDir>/.inbox/`) is owned by the
|
||||
// background extraction agent and the `/memory inbox` review flow. The
|
||||
// main agent must NOT drop files into it directly (that would let the
|
||||
// model bypass review). Deny first, even if the path also satisfies the
|
||||
// workspace or project-temp allowlists below.
|
||||
const inboxRoot = path.join(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
'.inbox',
|
||||
);
|
||||
const resolvedInboxRoot = resolveToRealPath(inboxRoot);
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const normalizedInboxRoot = path.resolve(inboxRoot);
|
||||
if (
|
||||
resolvedPath === resolvedInboxRoot ||
|
||||
isSubpath(resolvedInboxRoot, resolvedPath) ||
|
||||
normalizedPath === normalizedInboxRoot ||
|
||||
isSubpath(normalizedInboxRoot, normalizedPath)
|
||||
) {
|
||||
if (
|
||||
this.isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
inboxRoot,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
|
||||
return true;
|
||||
@@ -3122,6 +3208,19 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
absolutePath: string,
|
||||
checkType: 'read' | 'write' = 'write',
|
||||
): string | null {
|
||||
if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
|
||||
const resolvedPath = resolveToRealPath(absolutePath);
|
||||
if (
|
||||
this.isScopedAutoMemoryExtractionWritePathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `Auto-memory extraction write denied: Attempted path "${absolutePath}" is outside the extraction write allowlist. Extraction may only write extracted skills under ${this.storage.getProjectSkillsMemoryDir()} and canonical inbox patches under ${path.join(this.storage.getProjectMemoryTempDir(), '.inbox', '{private,global}', 'extraction.patch')}.`;
|
||||
}
|
||||
|
||||
// For read operations, check read-only paths first
|
||||
if (checkType === 'read') {
|
||||
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
|
||||
|
||||
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
|
||||
*/
|
||||
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
|
||||
const memoryInboxAccessOverride = new AsyncLocalStorage<boolean>();
|
||||
const autoMemoryExtractionWriteAccessOverride =
|
||||
new AsyncLocalStorage<boolean>();
|
||||
|
||||
/**
|
||||
* Returns the current workspace context override, if any.
|
||||
@@ -44,6 +47,42 @@ export function runWithScopedWorkspaceContext<T>(
|
||||
return workspaceContextOverride.run(scopedContext, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current async execution is allowed to access the
|
||||
* canonical auto-memory inbox patch files.
|
||||
*/
|
||||
export function hasScopedMemoryInboxAccess(): boolean {
|
||||
return memoryInboxAccessOverride.getStore() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with access to the canonical auto-memory inbox patch files.
|
||||
* This is intended for the background extraction agent only; the main agent
|
||||
* continues to have the inbox carved out of its normal temp-dir access.
|
||||
*/
|
||||
export function runWithScopedMemoryInboxAccess<T>(fn: () => T): T {
|
||||
return memoryInboxAccessOverride.run(true, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current async execution is using the narrow
|
||||
* auto-memory extraction write allowlist.
|
||||
*/
|
||||
export function hasScopedAutoMemoryExtractionWriteAccess(): boolean {
|
||||
return autoMemoryExtractionWriteAccessOverride.getStore() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with the auto-memory extraction write allowlist active.
|
||||
* This prevents the background extractor from writing active memory files
|
||||
* directly; it may only write extracted skills and canonical inbox patches.
|
||||
*/
|
||||
export function runWithScopedAutoMemoryExtractionWriteAccess<T>(
|
||||
fn: () => T,
|
||||
): T {
|
||||
return autoMemoryExtractionWriteAccessOverride.run(true, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link WorkspaceContext} that extends a parent's directories
|
||||
* with additional ones.
|
||||
|
||||
@@ -106,10 +106,6 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalAgentsDir(), 'skills');
|
||||
}
|
||||
|
||||
static getGlobalMemoryFilePath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
|
||||
}
|
||||
|
||||
static getUserPoliciesDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'policies');
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -226,7 +226,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -527,7 +527,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -707,7 +707,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -888,7 +888,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -1021,7 +1021,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -1636,7 +1636,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -1813,7 +1813,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -1981,7 +1981,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -2149,7 +2149,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -2313,7 +2313,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -2477,7 +2477,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -2635,7 +2635,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -2767,7 +2767,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -3059,7 +3059,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -3481,7 +3481,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -3645,7 +3645,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -3923,7 +3923,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
@@ -4087,7 +4087,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
|
||||
@@ -38,6 +38,7 @@ import * as policyHelpers from '../availability/policyHelpers.js';
|
||||
import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js';
|
||||
import type { HookSystem } from '../hooks/hookSystem.js';
|
||||
import { LlmRole } from '../telemetry/types.js';
|
||||
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
@@ -2575,6 +2576,153 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('automated binary injection', () => {
|
||||
it('should expand history with synthetic turns when __binary_injection__ is detected', async () => {
|
||||
const audioParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-123',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: 'Success',
|
||||
[BINARY_INJECTION_KEY]: [
|
||||
{ inlineData: { mimeType: 'audio/mpeg', data: 'base64' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock API to capture the history it receives
|
||||
let capturedContents: Content[] = [];
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async (req) => {
|
||||
capturedContents = req.contents as Content[];
|
||||
return (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Analysis done' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'gemini-pro' },
|
||||
audioParts,
|
||||
'test-id',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
for await (const _ of stream) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
// Verify history expansion
|
||||
// Turn 1: Tool response (cleaned)
|
||||
// Turn 2: Model Ack (synthetic)
|
||||
// Turn 3: User Binary data (current request)
|
||||
expect(capturedContents).toHaveLength(3);
|
||||
expect(capturedContents[0].role).toBe('user');
|
||||
expect(capturedContents[0].parts![0].functionResponse!.response).toEqual({
|
||||
output: 'Success',
|
||||
});
|
||||
expect(capturedContents[1].role).toBe('model');
|
||||
expect(capturedContents[1].parts![0].text).toContain(
|
||||
'Binary content received',
|
||||
);
|
||||
expect(capturedContents[1].parts![0].thoughtSignature).toBe(
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
);
|
||||
expect(capturedContents[2].role).toBe('user');
|
||||
expect(capturedContents[2].parts![0].inlineData!.mimeType).toBe(
|
||||
'audio/mpeg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple parallel binary injections', async () => {
|
||||
const parallelParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: 'Success 1',
|
||||
[BINARY_INJECTION_KEY]: [
|
||||
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio1' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-2',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: 'Success 2',
|
||||
[BINARY_INJECTION_KEY]: [
|
||||
{ inlineData: { mimeType: 'video/mp4', data: 'video2' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let capturedContents: Content[] = [];
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async (req) => {
|
||||
capturedContents = req.contents as Content[];
|
||||
return (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Done' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'gemini-pro' },
|
||||
parallelParts,
|
||||
'test-id',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
for await (const _ of stream) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
// Turn 1: Cleaned tool responses (both)
|
||||
// Turn 2: Model Ack
|
||||
// Turn 3: Both binary parts combined
|
||||
expect(capturedContents).toHaveLength(3);
|
||||
expect(capturedContents[0].parts).toHaveLength(2);
|
||||
expect(capturedContents[0].parts![0].functionResponse!.response).toEqual({
|
||||
output: 'Success 1',
|
||||
});
|
||||
expect(capturedContents[0].parts![1].functionResponse!.response).toEqual({
|
||||
output: 'Success 2',
|
||||
});
|
||||
expect(capturedContents[2].parts).toHaveLength(2);
|
||||
expect(capturedContents[2].parts![0].inlineData!.mimeType).toBe(
|
||||
'audio/mpeg',
|
||||
);
|
||||
expect(capturedContents[2].parts![1].inlineData!.mimeType).toBe(
|
||||
'video/mp4',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCompletedToolCalls', () => {
|
||||
it('should use originalRequestName and originalRequestArgs if present', () => {
|
||||
const completedCall: CompletedToolCall = {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { handleFallback } from '../fallback/handler.js';
|
||||
import { isFunctionResponse } from '../utils/messageInspectors.js';
|
||||
import { scrubHistory } from '../utils/historyHardening.js';
|
||||
import { partListUnionToString } from './geminiRequest.js';
|
||||
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
|
||||
import {
|
||||
@@ -336,7 +337,7 @@ export class GeminiChat {
|
||||
});
|
||||
this.sendPromise = streamDonePromise;
|
||||
|
||||
const userContent = createUserContent(message);
|
||||
let userContent = createUserContent(message);
|
||||
const { model } =
|
||||
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
|
||||
@@ -366,6 +367,30 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
// Add user content to history ONCE before any attempts.
|
||||
const binaryInjections = this.extractBinaryInjections(userContent.parts);
|
||||
if (binaryInjections) {
|
||||
// Turn 1: The original tool response (now cleaned)
|
||||
this.agentHistory.push(userContent);
|
||||
|
||||
// Turn 2: Synthetic Model Acknowledgment
|
||||
this.agentHistory.push({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'Binary content received. Proceeding with analysis.',
|
||||
thought: true,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Turn 3: The actual binary data (becomes the current request message)
|
||||
userContent = {
|
||||
role: 'user',
|
||||
parts: binaryInjections,
|
||||
};
|
||||
}
|
||||
|
||||
this.agentHistory.push(userContent);
|
||||
const requestContents = this.getHistory(true);
|
||||
|
||||
@@ -510,6 +535,32 @@ export class GeminiChat {
|
||||
return streamWithRetries.call(this);
|
||||
}
|
||||
|
||||
private extractBinaryInjections(
|
||||
parts: Part[] | undefined,
|
||||
): Part[] | undefined {
|
||||
if (!parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const binaryInjections: Part[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const response = part.functionResponse?.response;
|
||||
|
||||
if (response && BINARY_INJECTION_KEY in response) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const binaryParts = response[BINARY_INJECTION_KEY] as Part[];
|
||||
delete response[BINARY_INJECTION_KEY];
|
||||
|
||||
if (Array.isArray(binaryParts)) {
|
||||
binaryInjections.push(...binaryParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return binaryInjections.length > 0 ? binaryInjections : undefined;
|
||||
}
|
||||
|
||||
private async makeApiCallAndProcessStream(
|
||||
modelConfigKey: ModelConfigKey,
|
||||
requestContents: readonly Content[],
|
||||
|
||||
@@ -173,6 +173,215 @@ describe('HookTranslator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for https://github.com/google-gemini/gemini-cli/issues/25558
|
||||
// BeforeModel hooks that modify text in conversations containing tool calls
|
||||
// were destroying functionCall/functionResponse parts because
|
||||
// fromHookLLMRequest rebuilt contents text-only. The fix merges hook text
|
||||
// edits back into baseRequest.contents in place, preserving non-text parts.
|
||||
describe('fromHookLLMRequest with baseRequest (non-text part preservation)', () => {
|
||||
it('should preserve functionCall parts when merging hook text back', () => {
|
||||
const baseRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Let me check that.' },
|
||||
{ functionCall: { name: 'search', args: { q: 'test' } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'search',
|
||||
response: { results: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'No results found.' }],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentParameters;
|
||||
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello [MODIFIED]' },
|
||||
{ role: 'model', content: 'Let me check that.' },
|
||||
// contents[2] (functionResponse only) was skipped by toHookLLMRequest
|
||||
{ role: 'model', content: 'No results found.' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
|
||||
const contents = result.contents as Array<{
|
||||
role: string;
|
||||
parts: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
expect(contents).toHaveLength(4);
|
||||
|
||||
// First content: text updated
|
||||
expect(contents[0].parts[0]['text']).toBe('Hello [MODIFIED]');
|
||||
|
||||
// Second content: text updated AND functionCall preserved
|
||||
expect(contents[1].parts).toHaveLength(2);
|
||||
expect(contents[1].parts[0]['text']).toBe('Let me check that.');
|
||||
expect(contents[1].parts[1]['functionCall']).toBeDefined();
|
||||
|
||||
// Third content: functionResponse preserved as-is (was skipped)
|
||||
expect(contents[2].parts[0]['functionResponse']).toBeDefined();
|
||||
expect(contents[2].parts).toHaveLength(1);
|
||||
|
||||
// Fourth content: text updated
|
||||
expect(contents[3].parts[0]['text']).toBe('No results found.');
|
||||
});
|
||||
|
||||
it('should handle text-only entries interleaved with function-only entries', () => {
|
||||
const baseRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Q1' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'tool1', args: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'tool1',
|
||||
response: { ok: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: 'Answer' }] },
|
||||
],
|
||||
} as unknown as GenerateContentParameters;
|
||||
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Q1-modified' },
|
||||
// contents[1] and [2] skipped (no text)
|
||||
{ role: 'model', content: 'Answer-modified' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
|
||||
const contents = result.contents as Array<{
|
||||
role: string;
|
||||
parts: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
expect(contents).toHaveLength(4);
|
||||
expect(contents[0].parts[0]['text']).toBe('Q1-modified');
|
||||
expect(contents[1].parts[0]['functionCall']).toBeDefined();
|
||||
expect(contents[2].parts[0]['functionResponse']).toBeDefined();
|
||||
expect(contents[3].parts[0]['text']).toBe('Answer-modified');
|
||||
});
|
||||
|
||||
it('should collapse multiple text parts and preserve non-text parts', () => {
|
||||
const baseRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'I will search' },
|
||||
{ text: ' for you.' },
|
||||
{ functionCall: { name: 'search', args: {} } },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentParameters;
|
||||
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [
|
||||
{ role: 'model', content: 'I will search for you. [BLINDED]' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
|
||||
const contents = result.contents as Array<{
|
||||
role: string;
|
||||
parts: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
expect(contents).toHaveLength(1);
|
||||
const parts = contents[0].parts;
|
||||
// Multiple text parts collapsed to one, non-text preserved
|
||||
expect(parts[0]['text']).toBe('I will search for you. [BLINDED]');
|
||||
expect(parts[1]['functionCall']).toBeDefined();
|
||||
expect(parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should fall back to text-only when baseRequest is undefined', () => {
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
};
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest);
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fall back to text-only when baseRequest has no contents', () => {
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
};
|
||||
const baseRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
} as GenerateContentParameters;
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should append extra hook messages beyond base contents', () => {
|
||||
const baseRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
} as unknown as GenerateContentParameters;
|
||||
|
||||
const hookRequest: LLMRequest = {
|
||||
model: 'gemini-2.0-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'model', content: 'Extra message added by hook' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
|
||||
const contents = result.contents as Array<{
|
||||
role: string;
|
||||
parts: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents[1].parts[0]['text']).toBe('Extra message added by hook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LLM Response Translation', () => {
|
||||
it('should convert SDK response to hook format', () => {
|
||||
const sdkResponse: GenerateContentResponse = {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponse,
|
||||
GenerateContentParameters,
|
||||
Part,
|
||||
ToolConfig,
|
||||
FinishReason,
|
||||
FunctionCallingConfig,
|
||||
@@ -100,11 +102,10 @@ function hasTextProperty(value: unknown): value is { text: string } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if content has role and parts properties
|
||||
* Type guard to check if a value is a Content object (i.e. has role and parts
|
||||
* properties). Narrows to Content so callers can access `parts` as Part[].
|
||||
*/
|
||||
function isContentWithParts(
|
||||
content: unknown,
|
||||
): content is { role: string; parts: unknown } {
|
||||
function isContentWithParts(content: unknown): content is Content {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
@@ -226,22 +227,124 @@ export class HookTranslatorGenAIv1 extends HookTranslator {
|
||||
baseRequest?: GenerateContentParameters,
|
||||
): GenerateContentParameters {
|
||||
// Convert hook messages back to SDK Content format.
|
||||
//
|
||||
// When both hookRequest.messages and baseRequest.contents are present, we
|
||||
// merge the hook's text edits back into the original contents in place,
|
||||
// preserving non-text parts (functionCall, functionResponse, inlineData,
|
||||
// thought, etc.) that toHookLLMRequest filtered out for the simplified
|
||||
// hook API. Without this merge, a BeforeModel hook that modifies text
|
||||
// would destroy tool call/response history and cause the model to loop
|
||||
// (see https://github.com/google-gemini/gemini-cli/issues/25558).
|
||||
//
|
||||
// If the hook returned a partial request without messages (e.g. only
|
||||
// overriding `model`), fall back to the base request's contents so the
|
||||
// conversation is preserved.
|
||||
const contents = hookRequest.messages
|
||||
? hookRequest.messages.map((message) => ({
|
||||
role: message.role === 'model' ? 'model' : message.role,
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: String(message.content),
|
||||
},
|
||||
],
|
||||
}))
|
||||
: (baseRequest?.contents ?? []);
|
||||
let contents: GenerateContentParameters['contents'];
|
||||
|
||||
if (!hookRequest.messages) {
|
||||
contents = baseRequest?.contents ?? [];
|
||||
} else if (baseRequest?.contents) {
|
||||
// Merge hook messages back into base contents, preserving non-text parts.
|
||||
const baseContents = Array.isArray(baseRequest.contents)
|
||||
? baseRequest.contents
|
||||
: [baseRequest.contents];
|
||||
|
||||
// The merged result is uniformly Content[] — ContentListUnion does not
|
||||
// allow mixing strings (PartUnion) and Content objects in the same
|
||||
// array, so any string entries from baseContents are normalized to
|
||||
// Content here.
|
||||
const merged: Content[] = [];
|
||||
let messageIndex = 0;
|
||||
|
||||
const messageToContent = (
|
||||
message: LLMRequest['messages'][number],
|
||||
): Content => ({
|
||||
role: message.role === 'model' ? 'model' : message.role,
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: String(message.content),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const content of baseContents) {
|
||||
// Normalize each baseContents entry into a Content object so the
|
||||
// merged array is homogeneous.
|
||||
if (typeof content === 'string') {
|
||||
// String entries always contributed one message to the hook view.
|
||||
if (messageIndex < hookRequest.messages.length) {
|
||||
merged.push(messageToContent(hookRequest.messages[messageIndex++]));
|
||||
} else {
|
||||
merged.push({ role: 'user', parts: [{ text: content }] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isContentWithParts(content)) {
|
||||
// Bare Part object (PartUnion expansion: Content | Part | string).
|
||||
// toHookLLMRequest does not emit a message for these, so preserve
|
||||
// them as a single-part Content with a default role.
|
||||
merged.push({ role: 'user', parts: [content] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts: Part[] = content.parts ?? [];
|
||||
const hasText = parts.some(hasTextProperty);
|
||||
const baseContent: Content = { ...content, parts };
|
||||
|
||||
if (!hasText) {
|
||||
// toHookLLMRequest skipped this entry — preserve it untouched so
|
||||
// tool-call/response history is not lost.
|
||||
merged.push(baseContent);
|
||||
continue;
|
||||
}
|
||||
|
||||
// This entry contributed a message — merge the hook's text back in
|
||||
// and keep any non-text parts in their original order.
|
||||
if (messageIndex < hookRequest.messages.length) {
|
||||
const message = hookRequest.messages[messageIndex++];
|
||||
const newText =
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: String(message.content);
|
||||
const nonTextParts = parts.filter(
|
||||
(p): p is Part => !hasTextProperty(p),
|
||||
);
|
||||
|
||||
merged.push({
|
||||
...baseContent,
|
||||
role: message.role === 'model' ? 'model' : message.role,
|
||||
parts: [{ text: newText }, ...nonTextParts],
|
||||
});
|
||||
} else {
|
||||
merged.push(baseContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining hook messages beyond baseContents (the hook may
|
||||
// have added new turns).
|
||||
while (messageIndex < hookRequest.messages.length) {
|
||||
merged.push(messageToContent(hookRequest.messages[messageIndex++]));
|
||||
}
|
||||
|
||||
contents = merged;
|
||||
} else {
|
||||
// No baseRequest contents to merge against — fall back to text-only.
|
||||
contents = hookRequest.messages.map((message) => ({
|
||||
role: message.role === 'model' ? 'model' : message.role,
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: String(message.content),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// Build the result with proper typing.
|
||||
// Use nullish coalescing so a hook that only sets `model` still works --
|
||||
|
||||
@@ -262,7 +262,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations, e.g., "Can you tell me how to"). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, or whenever the user explicitly instructs you NOT to make changes just yet (e.g., "Don't make changes just yet", "Without changing anything"), your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a subsequent Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
|
||||
@@ -247,6 +247,27 @@ export type ApplyParsedSkillPatchesResult =
|
||||
export async function applyParsedSkillPatches(
|
||||
parsedPatches: StructuredPatch[],
|
||||
config: Config,
|
||||
): Promise<ApplyParsedSkillPatchesResult> {
|
||||
const allowedRoots = await getCanonicalAllowedSkillPatchRoots(config);
|
||||
return applyParsedPatchesWithAllowedRoots(parsedPatches, allowedRoots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies parsed unified diff patches against any caller-supplied set of
|
||||
* allowed root directories. This is the kind-agnostic core used by both the
|
||||
* skill patch flow and the memory patch flow.
|
||||
*
|
||||
* The patch headers must reference absolute paths inside one of the allowed
|
||||
* roots (after canonical resolution). Update patches must reference an
|
||||
* existing target; creation patches (`/dev/null` source) must reference a path
|
||||
* that does not yet exist.
|
||||
*
|
||||
* Returns the per-target before/after content so callers can stage commits
|
||||
* and roll back on failure.
|
||||
*/
|
||||
export async function applyParsedPatchesWithAllowedRoots(
|
||||
parsedPatches: StructuredPatch[],
|
||||
allowedRoots: string[],
|
||||
): Promise<ApplyParsedSkillPatchesResult> {
|
||||
const results = new Map<string, AppliedSkillPatchTarget>();
|
||||
const patchedContentByTarget = new Map<string, string>();
|
||||
@@ -260,9 +281,9 @@ export async function applyParsedSkillPatches(
|
||||
for (const [index, patch] of parsedPatches.entries()) {
|
||||
const { targetPath, isNewFile } = validatedHeaders.patches[index];
|
||||
|
||||
const resolvedTargetPath = await resolveAllowedSkillPatchTarget(
|
||||
const resolvedTargetPath = await resolveTargetWithinAllowedRoots(
|
||||
targetPath,
|
||||
config,
|
||||
allowedRoots,
|
||||
);
|
||||
if (!resolvedTargetPath) {
|
||||
return {
|
||||
@@ -337,3 +358,46 @@ export async function applyParsedSkillPatches(
|
||||
results: Array.from(results.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes a caller-supplied allowed root list once so callers can pass
|
||||
* raw `Storage` paths without each call doing realpath traversal.
|
||||
*/
|
||||
export async function canonicalizeAllowedPatchRoots(
|
||||
roots: string[],
|
||||
): Promise<string[]> {
|
||||
const canonicalRoots = await Promise.all(
|
||||
roots.map((root) => resolvePathWithExistingAncestors(root)),
|
||||
);
|
||||
return Array.from(
|
||||
new Set(
|
||||
canonicalRoots.filter((root): root is string => typeof root === 'string'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical target path if it falls inside (or exactly equals)
|
||||
* one of the supplied allowed roots, otherwise `undefined`. Allowed roots may
|
||||
* be either directories (subtree allowlist) or single file paths
|
||||
* (single-file allowlist) — `isSubpath(file, file)` returns true for the
|
||||
* same-path case.
|
||||
*
|
||||
* Exported so that `listInboxMemoryPatches` can pre-filter patches whose
|
||||
* headers escape the kind's allowed root, instead of surfacing them in the
|
||||
* UI just to fail at Apply time.
|
||||
*/
|
||||
export async function resolveTargetWithinAllowedRoots(
|
||||
targetPath: string,
|
||||
allowedRoots: string[],
|
||||
): Promise<string | undefined> {
|
||||
const canonicalTargetPath =
|
||||
await resolvePathWithExistingAncestors(targetPath);
|
||||
if (!canonicalTargetPath) {
|
||||
return undefined;
|
||||
}
|
||||
if (allowedRoots.some((root) => isSubpath(root, canonicalTargetPath))) {
|
||||
return canonicalTargetPath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ vi.mock('../agents/registry.js', () => ({
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getUserSkillsDir: vi.fn().mockReturnValue('/tmp/fake-user-skills'),
|
||||
getGlobalGeminiDir: vi.fn().mockReturnValue('/tmp/fake-global-gemini'),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -566,6 +567,109 @@ describe('memoryService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records inbox patches as memoryCandidatesCreated without applying them', async () => {
|
||||
const { startMemoryService, readExtractionState } = await import(
|
||||
'./memoryService.js'
|
||||
);
|
||||
const { LocalAgentExecutor } = await import(
|
||||
'../agents/local-executor.js'
|
||||
);
|
||||
|
||||
vi.mocked(coreEvents.emitFeedback).mockClear();
|
||||
vi.mocked(LocalAgentExecutor.create).mockReset();
|
||||
|
||||
const memoryDir = path.join(tmpDir, 'memory-inbox-only');
|
||||
const skillsDir = path.join(tmpDir, 'skills-inbox-only');
|
||||
const projectTempDir = path.join(tmpDir, 'temp-inbox-only');
|
||||
const chatsDir = path.join(projectTempDir, 'chats');
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'inbox-only-session',
|
||||
messageCount: 20,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(chatsDir, 'session-2025-01-01T00-00-inbox001.json'),
|
||||
JSON.stringify(conversation),
|
||||
);
|
||||
|
||||
vi.mocked(LocalAgentExecutor.create).mockResolvedValueOnce({
|
||||
run: vi.fn().mockImplementation(async () => {
|
||||
const inboxDir = path.join(memoryDir, '.inbox');
|
||||
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
|
||||
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'private', 'MEMORY.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${path.join(memoryDir, 'MEMORY.md')}`,
|
||||
`@@ -0,0 +1,1 @@`,
|
||||
`+- new project fact`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'global', 'reply-style.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ /workspace/global/GEMINI.md`,
|
||||
`@@ -0,0 +1,1 @@`,
|
||||
`+Prefer concise architecture summaries.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
return undefined;
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const mockConfig = {
|
||||
storage: {
|
||||
getProjectMemoryDir: vi.fn().mockReturnValue(memoryDir),
|
||||
getProjectMemoryTempDir: vi.fn().mockReturnValue(memoryDir),
|
||||
getProjectSkillsMemoryDir: vi.fn().mockReturnValue(skillsDir),
|
||||
getProjectTempDir: vi.fn().mockReturnValue(projectTempDir),
|
||||
},
|
||||
getToolRegistry: vi.fn(),
|
||||
getMessageBus: vi.fn(),
|
||||
getGeminiClient: vi.fn(),
|
||||
getSkillManager: vi.fn().mockReturnValue({ getSkills: () => [] }),
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: vi.fn(),
|
||||
},
|
||||
sandboxManager: undefined,
|
||||
} as unknown as Parameters<typeof startMemoryService>[0];
|
||||
|
||||
await startMemoryService(mockConfig);
|
||||
|
||||
// No patch was applied — active files do not exist.
|
||||
await expect(
|
||||
fs.access(path.join(memoryDir, 'MEMORY.md')),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Both patches remain in inbox awaiting review.
|
||||
for (const relativePath of [
|
||||
path.join('.inbox', 'private', 'MEMORY.patch'),
|
||||
path.join('.inbox', 'global', 'reply-style.patch'),
|
||||
]) {
|
||||
await expect(
|
||||
fs.access(path.join(memoryDir, relativePath)),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
|
||||
const state = await readExtractionState(
|
||||
path.join(memoryDir, '.extraction-state.json'),
|
||||
);
|
||||
expect(state.runs.at(-1)?.memoryFilesUpdated ?? []).toEqual([]);
|
||||
expect(state.runs.at(-1)?.memoryCandidatesCreated ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.join('.inbox', 'private', 'MEMORY.patch'),
|
||||
path.join('.inbox', 'global', 'reply-style.patch'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('records only sessions whose read_file completed successfully as processed', async () => {
|
||||
const { startMemoryService, readExtractionState } = await import(
|
||||
'./memoryService.js'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { constants as fsConstants, type Dirent } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as Diff from 'diff';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -45,6 +45,11 @@ import { sanitizeWorkflowSummaryForScratchpad } from './sessionScratchpadUtils.j
|
||||
const LOCK_FILENAME = '.extraction.lock';
|
||||
const STATE_FILENAME = '.extraction-state.json';
|
||||
const LOCK_STALE_MS = 35 * 60 * 1000; // 35 minutes (exceeds agent's 30-min time limit)
|
||||
// Throttle: skip background extraction if the most recent run finished less
|
||||
// than this long ago. Pairs with the advisory lock — the lock prevents
|
||||
// concurrent runs; this throttle prevents back-to-back runs across short
|
||||
// CLI sessions on workspaces with a lot of session history.
|
||||
const MIN_EXTRACTION_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const MIN_USER_MESSAGES = 10;
|
||||
const MIN_IDLE_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const MAX_SESSION_INDEX_SIZE = 50;
|
||||
@@ -78,6 +83,8 @@ export interface ExtractionRun {
|
||||
sessionIds: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
memoryCandidatesCreated?: string[];
|
||||
memoryFilesUpdated?: string[];
|
||||
skillsCreated: string[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
@@ -163,6 +170,8 @@ function isExtractionRunLike(value: unknown): value is {
|
||||
sessionIds?: unknown;
|
||||
candidateSessions?: unknown;
|
||||
processedSessions?: unknown;
|
||||
memoryCandidatesCreated?: unknown;
|
||||
memoryFilesUpdated?: unknown;
|
||||
skillsCreated: unknown;
|
||||
turnCount?: unknown;
|
||||
durationMs?: unknown;
|
||||
@@ -194,22 +203,44 @@ function buildExtractionRun(value: unknown): ExtractionRun | null {
|
||||
const candidateSessions = normalizeSessionVersions(value.candidateSessions);
|
||||
const processedSessions = normalizeSessionVersions(value.processedSessions);
|
||||
const sessionIds = normalizeStringArray(value.sessionIds);
|
||||
|
||||
return {
|
||||
const run: ExtractionRun = {
|
||||
runAt: value.runAt,
|
||||
sessionIds:
|
||||
sessionIds.length > 0
|
||||
? sessionIds
|
||||
: processedSessions.map((session) => session.sessionId),
|
||||
candidateSessions:
|
||||
candidateSessions.length > 0 ? candidateSessions : undefined,
|
||||
processedSessions:
|
||||
processedSessions.length > 0 ? processedSessions : undefined,
|
||||
skillsCreated: normalizeStringArray(value.skillsCreated),
|
||||
turnCount: normalizeOptionalNumber(value.turnCount),
|
||||
durationMs: normalizeOptionalNumber(value.durationMs),
|
||||
terminateReason: normalizeOptionalString(value.terminateReason),
|
||||
};
|
||||
|
||||
if (candidateSessions.length > 0) {
|
||||
run.candidateSessions = candidateSessions;
|
||||
}
|
||||
if (processedSessions.length > 0) {
|
||||
run.processedSessions = processedSessions;
|
||||
}
|
||||
if ('memoryCandidatesCreated' in value) {
|
||||
run.memoryCandidatesCreated = normalizeStringArray(
|
||||
value.memoryCandidatesCreated,
|
||||
);
|
||||
}
|
||||
if ('memoryFilesUpdated' in value) {
|
||||
run.memoryFilesUpdated = normalizeStringArray(value.memoryFilesUpdated);
|
||||
}
|
||||
|
||||
const turnCount = normalizeOptionalNumber(value.turnCount);
|
||||
if (turnCount !== undefined) {
|
||||
run.turnCount = turnCount;
|
||||
}
|
||||
const durationMs = normalizeOptionalNumber(value.durationMs);
|
||||
if (durationMs !== undefined) {
|
||||
run.durationMs = durationMs;
|
||||
}
|
||||
const terminateReason = normalizeOptionalString(value.terminateReason);
|
||||
if (terminateReason !== undefined) {
|
||||
run.terminateReason = terminateReason;
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
function getTimestampMs(timestamp: string): number {
|
||||
@@ -897,6 +928,164 @@ export async function validatePatches(
|
||||
return validPatches;
|
||||
}
|
||||
|
||||
type FileSnapshot = Map<string, string>;
|
||||
|
||||
async function snapshotFiles(
|
||||
rootDir: string,
|
||||
shouldIncludeFile: (relativePath: string) => boolean = () => true,
|
||||
shouldDescendDirectory: (relativePath: string) => boolean = () => true,
|
||||
): Promise<FileSnapshot> {
|
||||
const snapshot: FileSnapshot = new Map();
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
let entries: Array<Dirent<string>>;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
const relativePath = path.relative(rootDir, absolutePath);
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldDescendDirectory(relativePath)) {
|
||||
await walk(absolutePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !shouldIncludeFile(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
snapshot.set(relativePath, await fs.readFile(absolutePath, 'utf-8'));
|
||||
} catch {
|
||||
// Best-effort snapshot: ignore files that disappear or are unreadable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDir);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function snapshotInboxCandidates(
|
||||
memoryDir: string,
|
||||
): Promise<FileSnapshot> {
|
||||
return snapshotFiles(path.join(memoryDir, '.inbox'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary of the current memory inbox state, grouped
|
||||
* by kind and showing the contents of each `.patch` file. Used as part of the
|
||||
* extraction agent's initial context so the agent can extend existing
|
||||
* canonical patches in-place rather than creating new files each session.
|
||||
*
|
||||
* Returns an empty string if the inbox is empty.
|
||||
*/
|
||||
async function buildPendingInboxSummary(memoryDir: string): Promise<string> {
|
||||
const sections: string[] = [];
|
||||
for (const kind of ['private', 'global'] as const) {
|
||||
const kindRoot = path.join(memoryDir, '.inbox', kind);
|
||||
let entries: Array<Dirent<string>>;
|
||||
try {
|
||||
entries = await fs.readdir(kindRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const patchFiles = entries
|
||||
.filter((e) => e.isFile() && e.name.endsWith('.patch'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
if (patchFiles.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filesSection: string[] = [`## ${kind} (${patchFiles.length})`];
|
||||
for (const fileName of patchFiles) {
|
||||
const fullPath = path.join(kindRoot, fileName);
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(fullPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Guard against indirect prompt injection: patch contents originate
|
||||
// from past sessions (which may include user-pasted text), so a
|
||||
// crafted payload could include a closing ``` fence to break out of
|
||||
// the surrounding markdown block. Pick a fence longer than the
|
||||
// longest backtick-run actually present in the content so the close
|
||||
// is guaranteed to terminate the block.
|
||||
const longestBacktickRun = (content.match(/`+/g) ?? []).reduce(
|
||||
(max, run) => Math.max(max, run.length),
|
||||
2, // never go below the standard 3-backtick fence
|
||||
);
|
||||
const fence = '`'.repeat(longestBacktickRun + 1);
|
||||
filesSection.push('');
|
||||
filesSection.push(`### ${fileName}`);
|
||||
filesSection.push(fence);
|
||||
filesSection.push(content.trimEnd());
|
||||
filesSection.push(fence);
|
||||
}
|
||||
sections.push(filesSection.join('\n'));
|
||||
}
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
interface FileSnapshotDiff {
|
||||
added: string[];
|
||||
updated: string[];
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
function diffFileSnapshots(
|
||||
before: FileSnapshot,
|
||||
after: FileSnapshot,
|
||||
): FileSnapshotDiff {
|
||||
const added: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
|
||||
for (const [relativePath, content] of after) {
|
||||
if (!before.has(relativePath)) {
|
||||
added.push(relativePath);
|
||||
} else if (before.get(relativePath) !== content) {
|
||||
updated.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of before.keys()) {
|
||||
if (!after.has(relativePath)) {
|
||||
deleted.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
added: added.sort(),
|
||||
updated: updated.sort(),
|
||||
deleted: deleted.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function getChangedSnapshotPaths(diff: FileSnapshotDiff): string[] {
|
||||
return [...diff.added, ...diff.updated].sort();
|
||||
}
|
||||
|
||||
function prefixRelativePaths(
|
||||
prefix: string,
|
||||
relativePaths: string[],
|
||||
): string[] {
|
||||
return relativePaths.map((relativePath) => path.join(prefix, relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for the skill extraction background task.
|
||||
* Designed to be called fire-and-forget on session startup.
|
||||
@@ -947,6 +1136,24 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
`[MemoryService] State loaded: ${previousRuns} previous run(s), ${previouslyProcessed} session(s) already processed`,
|
||||
);
|
||||
|
||||
// Throttle: short-circuit if the most recent run finished less than
|
||||
// MIN_EXTRACTION_INTERVAL_MS ago. Avoids re-scanning session history on
|
||||
// every CLI start when the user opens several short sessions in a row.
|
||||
const lastRun = state.runs.at(-1);
|
||||
if (lastRun?.runAt) {
|
||||
const lastRunMs = Date.parse(lastRun.runAt);
|
||||
if (
|
||||
Number.isFinite(lastRunMs) &&
|
||||
Date.now() - lastRunMs < MIN_EXTRACTION_INTERVAL_MS
|
||||
) {
|
||||
const minutesAgo = Math.round((Date.now() - lastRunMs) / 60000);
|
||||
debugLogger.log(
|
||||
`[MemoryService] Skipped: last run was ${minutesAgo} minute(s) ago (min interval ${MIN_EXTRACTION_INTERVAL_MS / 60000}m)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build session index: all eligible sessions with summaries + file paths.
|
||||
// The agent decides which to read in full via read_file.
|
||||
const { sessionIndex, newSessionIds, candidateSessions } =
|
||||
@@ -988,6 +1195,8 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
`[MemoryService] ${skillsBefore.size} existing skill(s) in memory`,
|
||||
);
|
||||
|
||||
const inboxCandidatesBefore = await snapshotInboxCandidates(memoryDir);
|
||||
|
||||
// Read existing skills for context (memory-extracted + global/workspace)
|
||||
const existingSkillsSummary = await buildExistingSkillsSummary(
|
||||
skillsDir,
|
||||
@@ -999,11 +1208,23 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the current inbox state to the agent so it can rewrite
|
||||
// existing canonical patches in place instead of accumulating new ones
|
||||
// across sessions.
|
||||
const pendingInboxSummary = await buildPendingInboxSummary(memoryDir);
|
||||
if (pendingInboxSummary) {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Pending inbox surfaced to agent:\n${pendingInboxSummary}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build agent definition and context
|
||||
const agentDefinition = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
memoryDir,
|
||||
pendingInboxSummary,
|
||||
);
|
||||
|
||||
const context = buildAgentLoopContext(config);
|
||||
@@ -1109,6 +1330,18 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Anything still in .inbox/ is reviewable; nothing is auto-applied.
|
||||
const memoryFilesUpdated: string[] = [];
|
||||
const memoryCandidatesCreated = prefixRelativePaths(
|
||||
'.inbox',
|
||||
getChangedSnapshotPaths(
|
||||
diffFileSnapshots(
|
||||
inboxCandidatesBefore,
|
||||
await snapshotInboxCandidates(memoryDir),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const processedSessions = candidateSessions
|
||||
.filter((session) =>
|
||||
processedSessionKeys.has(getSessionVersionKey(session)),
|
||||
@@ -1127,6 +1360,8 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
lastUpdated: session.lastUpdated,
|
||||
})),
|
||||
processedSessions,
|
||||
memoryCandidatesCreated,
|
||||
memoryFilesUpdated,
|
||||
skillsCreated,
|
||||
turnCount: normalizeOptionalNumber(executorResult?.turn_count),
|
||||
durationMs: normalizeOptionalNumber(executorResult?.duration_ms),
|
||||
@@ -1139,8 +1374,17 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
};
|
||||
await writeExtractionState(statePath, updatedState);
|
||||
|
||||
if (skillsCreated.length > 0 || patchesCreatedThisRun.length > 0) {
|
||||
if (
|
||||
skillsCreated.length > 0 ||
|
||||
patchesCreatedThisRun.length > 0 ||
|
||||
memoryCandidatesCreated.length > 0
|
||||
) {
|
||||
const completionParts: string[] = [];
|
||||
if (memoryCandidatesCreated.length > 0) {
|
||||
completionParts.push(
|
||||
`prepared ${memoryCandidatesCreated.length} memory candidate(s): ${memoryCandidatesCreated.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (skillsCreated.length > 0) {
|
||||
completionParts.push(
|
||||
`created ${skillsCreated.length} skill(s): ${skillsCreated.join(', ')}`,
|
||||
@@ -1155,6 +1399,11 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (read ${processedSessions.length}/${candidateSessions.length} surfaced session(s))`,
|
||||
);
|
||||
const feedbackParts: string[] = [];
|
||||
if (memoryCandidatesCreated.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${memoryCandidatesCreated.length} memory candidate${memoryCandidatesCreated.length > 1 ? 's' : ''} extracted from past sessions`,
|
||||
);
|
||||
}
|
||||
if (skillsCreated.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${skillsCreated.length} new skill${skillsCreated.length > 1 ? 's' : ''} extracted from past sessions: ${skillsCreated.join(', ')}`,
|
||||
|
||||
@@ -1390,6 +1390,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
cp.stdout?.emit('data', Buffer.from(chunk2));
|
||||
cp.stdout?.emit('data', Buffer.from(chunk3));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
const truncationMessage =
|
||||
@@ -1577,6 +1578,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
cp.stdout?.emit('data', binaryChunk1);
|
||||
cp.stdout?.emit('data', binaryChunk2);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
@@ -1641,6 +1643,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1658,6 +1661,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
await simulateExecution('ls "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1772,6 +1776,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).not.toHaveBeenCalled();
|
||||
@@ -1795,6 +1800,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).toHaveBeenCalled();
|
||||
|
||||
@@ -778,7 +778,7 @@ export class ShellExecutionService {
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child.on('close', (code, signal) => {
|
||||
handleExit(code, signal);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import v8 from 'node:v8';
|
||||
import fs from 'node:fs';
|
||||
import { captureHeapSnapshot } from './heap-snapshot.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
vi.mock('node:v8');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('heap-snapshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should capture a heap snapshot to a secure directory', () => {
|
||||
vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-heap-abc123');
|
||||
|
||||
const filePath = captureHeapSnapshot();
|
||||
|
||||
expect(filePath).toContain('gemini-heap-abc123');
|
||||
expect(filePath).toContain('.heapsnapshot');
|
||||
expect(v8.writeHeapSnapshot).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it('should return null and log an error if capture fails', () => {
|
||||
vi.mocked(fs.mkdtempSync).mockImplementation(() => {
|
||||
throw new Error('Disk full');
|
||||
});
|
||||
|
||||
const result = captureHeapSnapshot();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to capture heap snapshot'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Utility to capture a V8 heap snapshot.
|
||||
* Snapshots are saved to a secure, uniquely named temporary directory.
|
||||
*
|
||||
* @returns The absolute path to the generated .heapsnapshot file, or null if it failed.
|
||||
*/
|
||||
export function captureHeapSnapshot(): string | null {
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const filename = `gemini-heap-${timestamp}.heapsnapshot`;
|
||||
|
||||
// Use mkdtempSync for a secure, uniquely named directory (mitigates symlink attacks)
|
||||
const snapshotsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-heap-'));
|
||||
const filePath = path.join(snapshotsDir, filename);
|
||||
|
||||
// Note: v8.writeHeapSnapshot is a synchronous, blocking operation.
|
||||
// This is intentional during diagnostics to capture a consistent heap state.
|
||||
v8.writeHeapSnapshot(filePath);
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Telemetry/diagnostic failures should not crash the application
|
||||
debugLogger.error('Failed to capture heap snapshot:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export {
|
||||
startGlobalMemoryMonitoring,
|
||||
stopGlobalMemoryMonitoring,
|
||||
} from './memory-monitor.js';
|
||||
export { captureHeapSnapshot } from './heap-snapshot.js';
|
||||
export type { MemorySnapshot, ProcessMetrics } from './memory-monitor.js';
|
||||
export {
|
||||
EventLoopMonitor,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isPerformanceMonitoringActive,
|
||||
} from './metrics.js';
|
||||
import { RateLimiter } from './rate-limiter.js';
|
||||
import { captureHeapSnapshot } from './heap-snapshot.js';
|
||||
|
||||
export interface MemorySnapshot {
|
||||
timestamp: number;
|
||||
@@ -386,6 +387,14 @@ export class MemoryMonitor {
|
||||
this.highWaterMarkTracker.resetAllHighWaterMarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a V8 heap snapshot for memory diagnostics.
|
||||
* @returns The absolute path to the generated .heapsnapshot file, or null if it failed.
|
||||
*/
|
||||
captureHeapSnapshot(): string | null {
|
||||
return captureHeapSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AskUserTool, isCompletedAskUserTool } from './ask-user.js';
|
||||
import {
|
||||
AskUserTool,
|
||||
isCompletedAskUserTool,
|
||||
type AskUserParams,
|
||||
type AskUserInvocation,
|
||||
} from './ask-user.js';
|
||||
import { QuestionType, type Question } from '../confirmation-bus/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
@@ -63,6 +68,80 @@ describe('AskUserTool', () => {
|
||||
expect(tool.displayName).toBe('Ask User');
|
||||
});
|
||||
|
||||
describe('createInvocation and normalization', () => {
|
||||
it('should unescape double-escaped newlines in question parameters', async () => {
|
||||
const params: AskUserParams = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Line 1\\nLine 2',
|
||||
header: 'Header\\nTest',
|
||||
placeholder: 'Placeholder\\nTest',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'Option\\n1', description: 'Desc\\n1' },
|
||||
{ label: 'Option\\n2', description: 'Desc\\n2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (
|
||||
params: AskUserParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
toolDisplayName: string,
|
||||
) => AskUserInvocation;
|
||||
}
|
||||
).createInvocation(params, mockMessageBus, 'ask_user', 'Ask User');
|
||||
const details = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
if (!details || details.type !== 'ask_user') {
|
||||
throw new Error('Expected ask_user details');
|
||||
}
|
||||
|
||||
expect(details.questions[0].question).toBe('Line 1\nLine 2');
|
||||
expect(details.questions[0].header).toBe('Header\nTest');
|
||||
expect(details.questions[0].placeholder).toBe('Placeholder\nTest');
|
||||
expect(details.questions[0].options?.[0].label).toBe('Option\n1');
|
||||
expect(details.questions[0].options?.[0].description).toBe('Desc\n1');
|
||||
});
|
||||
|
||||
it('should handle carriage returns and literal newlines', async () => {
|
||||
const params: AskUserParams = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Line 1\\r\\nLine 2\nLine 3',
|
||||
header: 'Header',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
],
|
||||
};
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (
|
||||
params: AskUserParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
toolDisplayName: string,
|
||||
) => AskUserInvocation;
|
||||
}
|
||||
).createInvocation(params, mockMessageBus, 'ask_user', 'Ask User');
|
||||
const details = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
if (!details || details.type !== 'ask_user') {
|
||||
throw new Error('Expected ask_user details');
|
||||
}
|
||||
|
||||
expect(details.questions[0].question).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should return error if questions is missing', () => {
|
||||
// @ts-expect-error - Intentionally invalid params
|
||||
|
||||
@@ -93,7 +93,38 @@ export class AskUserTool extends BaseDeclarativeTool<
|
||||
toolName: string,
|
||||
toolDisplayName: string,
|
||||
): AskUserInvocation {
|
||||
return new AskUserInvocation(params, messageBus, toolName, toolDisplayName);
|
||||
const unescape = (str: string): string =>
|
||||
str.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
|
||||
|
||||
const normalizedParams: AskUserParams = {
|
||||
questions: params.questions.map((q) => {
|
||||
const normalizedQ: Question = {
|
||||
...q,
|
||||
type: q.type,
|
||||
question: unescape(q.question),
|
||||
};
|
||||
if (q.header) normalizedQ.header = unescape(q.header);
|
||||
if (q.placeholder) normalizedQ.placeholder = unescape(q.placeholder);
|
||||
|
||||
if (q.options) {
|
||||
normalizedQ.options = q.options.map((opt) => ({
|
||||
...opt,
|
||||
label: unescape(opt.label),
|
||||
description: opt.description?.trim()
|
||||
? unescape(opt.description.trim())
|
||||
: '',
|
||||
}));
|
||||
}
|
||||
return normalizedQ;
|
||||
}),
|
||||
};
|
||||
|
||||
return new AskUserInvocation(
|
||||
normalizedParams,
|
||||
messageBus,
|
||||
toolName,
|
||||
toolDisplayName,
|
||||
);
|
||||
}
|
||||
|
||||
override async validateBuildAndExecute(
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||
|
||||
@@ -105,9 +106,11 @@ describe('mcp-client', () => {
|
||||
workspaceContext = new WorkspaceContext(testWorkspace);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await cleanupTmpDir(testWorkspace);
|
||||
workspaceContext = null as unknown as WorkspaceContext;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('McpClient', () => {
|
||||
@@ -2410,7 +2413,10 @@ describe('connectToMcpServer with OAuth', () => {
|
||||
vi.mocked(MCPOAuthProvider).mockReturnValue(mockAuthProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await cleanupTmpDir(testWorkspace);
|
||||
workspaceContext = null as unknown as WorkspaceContext;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -2617,7 +2623,10 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await cleanupTmpDir(testWorkspace);
|
||||
workspaceContext = null as unknown as WorkspaceContext;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -2780,7 +2789,10 @@ describe('connectToMcpServer - OAuth with transport fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await cleanupTmpDir(testWorkspace);
|
||||
workspaceContext = null as unknown as WorkspaceContext;
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -158,6 +158,57 @@ describe('generateContentResponseUtilities', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out audio/video MIME types and add a minimal system note (generic tool)', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ text: 'Some text' },
|
||||
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio_data' } },
|
||||
];
|
||||
|
||||
const result = convertToFunctionResponse(
|
||||
'other_tool',
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
|
||||
const frPart = result.find((p) => p.functionResponse);
|
||||
const response: Record<string, unknown> = {};
|
||||
if (frPart?.functionResponse?.response) {
|
||||
Object.assign(response, frPart.functionResponse.response);
|
||||
}
|
||||
const output = response['output'] as string;
|
||||
expect(output).toContain(
|
||||
'[SYSTEM: Binary content (audio/mpeg) stripped from response due to protocol limitations.]',
|
||||
);
|
||||
expect(output).not.toContain('__binary_injection__');
|
||||
});
|
||||
|
||||
it('should use the __binary_injection__ flag for read_file and read_many_files tools', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ text: 'Reading audio' },
|
||||
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio_data' } },
|
||||
];
|
||||
|
||||
for (const tool of ['read_file', 'read_many_files']) {
|
||||
const result = convertToFunctionResponse(
|
||||
tool,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
|
||||
const frPart = result.find((p) => p.functionResponse);
|
||||
const response: Record<string, unknown> = {};
|
||||
if (frPart?.functionResponse?.response) {
|
||||
Object.assign(response, frPart.functionResponse.response);
|
||||
}
|
||||
expect(response['output']).toContain('read successfully');
|
||||
expect(response['__binary_injection__']).toBeDefined();
|
||||
const injection = response['__binary_injection__'] as Part[];
|
||||
expect(injection[0].inlineData?.mimeType).toBe('audio/mpeg');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle llmContent with fileData for Gemini 3 model (should be siblings)', () => {
|
||||
const llmContent: Part = {
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
|
||||
@@ -15,6 +15,8 @@ import { supportsMultimodalFunctionResponse } from '../config/models.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
export const BINARY_INJECTION_KEY = '__binary_injection__';
|
||||
|
||||
/**
|
||||
* Formats tool output for a Gemini FunctionResponse.
|
||||
*/
|
||||
@@ -89,6 +91,43 @@ export function convertToFunctionResponse(
|
||||
// Ignore other part types
|
||||
}
|
||||
|
||||
// build a list of unsupported MIME types for function responses
|
||||
const filteredInlineDataParts: Part[] = [];
|
||||
const unsupportedInlineDataParts: Part[] = [];
|
||||
|
||||
for (const part of inlineDataParts) {
|
||||
const mimeType = part.inlineData?.mimeType;
|
||||
if (
|
||||
mimeType &&
|
||||
(mimeType.startsWith('audio/') || mimeType.startsWith('video/'))
|
||||
) {
|
||||
unsupportedInlineDataParts.push(part);
|
||||
} else {
|
||||
filteredInlineDataParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedInlineDataParts.length > 0) {
|
||||
const uniqueMimes = Array.from(
|
||||
new Set(
|
||||
unsupportedInlineDataParts.map((p) => p.inlineData?.mimeType ?? ''),
|
||||
),
|
||||
).join(', ');
|
||||
|
||||
const isReadFileTool =
|
||||
toolName === 'read_file' || toolName === 'read_many_files';
|
||||
|
||||
if (isReadFileTool) {
|
||||
textParts.unshift(
|
||||
`Binary content (${uniqueMimes}) read successfully. Content will be injected for analysis in the next sequence.`,
|
||||
);
|
||||
} else {
|
||||
textParts.unshift(
|
||||
`[SYSTEM: Binary content (${uniqueMimes}) stripped from response due to protocol limitations.]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the primary response part
|
||||
const part: Part = {
|
||||
functionResponse: {
|
||||
@@ -98,30 +137,40 @@ export function convertToFunctionResponse(
|
||||
},
|
||||
};
|
||||
|
||||
const isReadFileTool =
|
||||
toolName === 'read_file' || toolName === 'read_many_files';
|
||||
|
||||
if (unsupportedInlineDataParts.length > 0 && isReadFileTool) {
|
||||
if (part.functionResponse) {
|
||||
Object.assign(part.functionResponse.response!, {
|
||||
[BINARY_INJECTION_KEY]: unsupportedInlineDataParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isMultimodalFRSupported = supportsMultimodalFunctionResponse(
|
||||
model,
|
||||
config,
|
||||
);
|
||||
const siblingParts: Part[] = [...fileDataParts];
|
||||
|
||||
if (inlineDataParts.length > 0) {
|
||||
if (filteredInlineDataParts.length > 0) {
|
||||
if (isMultimodalFRSupported) {
|
||||
// Nest inlineData if supported by the model
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(part.functionResponse as unknown as { parts: Part[] }).parts =
|
||||
inlineDataParts;
|
||||
Object.assign(part.functionResponse!, { parts: filteredInlineDataParts });
|
||||
} else {
|
||||
// Otherwise treat as siblings
|
||||
siblingParts.push(...inlineDataParts);
|
||||
siblingParts.push(...filteredInlineDataParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Add descriptive text if the response object is empty but we have binary content
|
||||
if (
|
||||
textParts.length === 0 &&
|
||||
(inlineDataParts.length > 0 || fileDataParts.length > 0)
|
||||
(filteredInlineDataParts.length > 0 || fileDataParts.length > 0)
|
||||
) {
|
||||
const totalBinaryItems = inlineDataParts.length + fileDataParts.length;
|
||||
const totalBinaryItems =
|
||||
filteredInlineDataParts.length + fileDataParts.length;
|
||||
part.functionResponse!.response = {
|
||||
output: `Binary content provided (${totalBinaryItems} item(s)).`,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnAsync } from './shell-utils.js';
|
||||
|
||||
/**
|
||||
* Gets the absolute path to the git directory (.git) for the given working directory.
|
||||
* This handles standard git repositories, subdirectories, and worktrees.
|
||||
*/
|
||||
export async function getAbsoluteGitDir(cwd: string): Promise<string> {
|
||||
const result = await spawnAsync('git', ['rev-parse', '--absolute-git-dir'], {
|
||||
cwd,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a directory is within a git repository
|
||||
|
||||
@@ -16,6 +16,27 @@ import {
|
||||
import { GeminiCliSession } from './session.js';
|
||||
import type { GeminiCliAgentOptions } from './types.js';
|
||||
|
||||
/**
|
||||
* The main entry point for the Gemini CLI SDK.
|
||||
*
|
||||
* An agent encapsulates configuration (instructions, tools, skills, model)
|
||||
* and can create new sessions or resume existing ones.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new GeminiCliAgent({
|
||||
* instructions: 'You are a helpful coding assistant.',
|
||||
* tools: [myTool],
|
||||
* });
|
||||
*
|
||||
* const session = agent.session();
|
||||
* await session.initialize();
|
||||
*
|
||||
* for await (const event of session.sendStream('Hello!')) {
|
||||
* console.log(event);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class GeminiCliAgent {
|
||||
private options: GeminiCliAgentOptions;
|
||||
|
||||
@@ -23,11 +44,28 @@ export class GeminiCliAgent {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new conversation session.
|
||||
*
|
||||
* @param options - Optional session configuration. Pass `{ sessionId }` to
|
||||
* use a specific session ID; otherwise a new one is generated.
|
||||
* @returns A new {@link GeminiCliSession} instance.
|
||||
*/
|
||||
session(options?: { sessionId?: string }): GeminiCliSession {
|
||||
const sessionId = options?.sessionId || createSessionId();
|
||||
return new GeminiCliSession(this.options, sessionId, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a previously created session by its ID.
|
||||
*
|
||||
* Looks up the session's conversation history from storage and replays it
|
||||
* so the agent can continue the conversation.
|
||||
*
|
||||
* @param sessionId - The ID of the session to resume.
|
||||
* @returns A {@link GeminiCliSession} with the prior conversation loaded.
|
||||
* @throws {Error} If no sessions exist or the specified ID is not found.
|
||||
*/
|
||||
async resumeSession(sessionId: string): Promise<GeminiCliSession> {
|
||||
const cwd = this.options.cwd || process.cwd();
|
||||
const storage = new Storage(cwd);
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { Config as CoreConfig } from '@google/gemini-cli-core';
|
||||
import type { AgentFilesystem } from './types.js';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* SDK implementation of {@link AgentFilesystem} that enforces path-based
|
||||
* access policies from the core Config.
|
||||
*
|
||||
* Read operations return `null` when access is denied; write operations
|
||||
* throw an error.
|
||||
*/
|
||||
export class SdkAgentFilesystem implements AgentFilesystem {
|
||||
constructor(private readonly config: CoreConfig) {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user