Merge branch 'main' into akkr/subagents

This commit is contained in:
AK
2026-03-03 11:16:46 -08:00
committed by GitHub
52 changed files with 1018 additions and 152 deletions
+99
View File
@@ -19,6 +19,8 @@ import {
debugLogger,
ApprovalMode,
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => {
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
describe('extension plan settings', () => {
beforeEach(() => {
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
'/mock/home/user/.gemini/tmp/test-project',
);
});
it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
});
it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: false,
plan: { directory: 'ext-plans-dir-inactive' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).not.toContain(
'ext-plans-dir-inactive',
);
});
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
// No extensions providing plan directory
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
const config = await loadCliConfig(settings, 'test-session', argv);
// Should return the default managed temp directory path
expect(config.storage.getPlansDir()).toBe(
path.join(
'/mock',
'home',
'user',
'.gemini',
'tmp',
'test-project',
'test-session',
'plans',
),
);
});
});
});
+7 -1
View File
@@ -511,6 +511,10 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent: string | HierarchicalMemory = '';
@@ -827,7 +831,9 @@ export async function loadCliConfig(
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -903,6 +903,7 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
+9
View File
@@ -33,6 +33,15 @@ export interface ExtensionConfig {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}
export interface ExtensionUpdateInfo {
+7 -2
View File
@@ -117,6 +117,10 @@ export interface SettingDefinition {
* For map-like objects without explicit `properties`, describes the shape of the values.
*/
additionalProperties?: SettingCollectionDefinition;
/**
* Optional unit to display after the value (e.g. '%').
*/
unit?: string;
/**
* Optional reference identifier for generators that emit a `$ref`.
*/
@@ -595,7 +599,7 @@ const SETTINGS_SCHEMA = {
category: 'UI',
requiresRestart: false,
default: true,
description: 'Hides the context window remaining percentage.',
description: 'Hides the context window usage percentage.',
showInDialog: true,
},
},
@@ -913,13 +917,14 @@ const SETTINGS_SCHEMA = {
},
compressionThreshold: {
type: 'number',
label: 'Compression Threshold',
label: 'Context Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
unit: '%',
},
disableLoopDetection: {
type: 'boolean',
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},
LoadedSettings: class {
constructor() {
// this.merged = {};
}
},
}));
describe('ContextUsageDisplay', () => {
it('renders correct percentage left', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correct percentage used', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context left');
expect(output).toContain('50% context used');
unmount();
});
it('renders short label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correctly when usage is 0%', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80%');
expect(output).not.toContain('context left');
expect(output).toContain('20%');
expect(output).not.toContain('context used');
unmount();
});
it('renders 0% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders 80% correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context left');
expect(output).toContain('100% context used');
unmount();
});
});
@@ -7,6 +7,11 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { getContextUsagePercentage } from '../utils/contextUsage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
DEFAULT_COMPRESSION_THRESHOLD,
} from '../constants.js';
export const ContextUsageDisplay = ({
promptTokenCount,
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
terminalWidth,
}: {
promptTokenCount: number;
model: string;
model: string | undefined;
terminalWidth: number;
}) => {
const settings = useSettings();
const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const percentageUsed = (percentage * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
const threshold =
settings.merged.model?.compressionThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
let textColor = theme.text.secondary;
if (percentage >= 1.0) {
textColor = theme.status.error;
} else if (percentage >= threshold) {
textColor = theme.status.warning;
}
const label =
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
return (
<Text color={theme.text.secondary}>
{percentageLeft}
<Text color={textColor}>
{percentageUsed}
{label}
</Text>
);
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
unmount();
});
it('hides the F12 hint in low error verbosity mode', async () => {
it('shows the F12 hint even in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
@@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(F12 to close)');
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -13,8 +13,6 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3;
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
+50 -6
View File
@@ -15,6 +15,19 @@ import {
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
let mockIsDevelopment = false;
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../utils/installationInfo.js')>();
return {
...original,
get isDevelopment() {
return mockIsDevelopment;
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -161,7 +174,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
@@ -216,7 +229,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -249,7 +262,7 @@ describe('<Footer />', () => {
unmount();
});
it('displays the model name and abbreviated context percentage', async () => {
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -267,6 +280,7 @@ describe('<Footer />', () => {
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
unmount();
});
@@ -464,7 +478,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context left/);
expect(lastFrame()).not.toMatch(/\d+% context used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -484,7 +498,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -509,7 +523,15 @@ describe('<Footer />', () => {
});
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
beforeEach(() => {
mockIsDevelopment = false;
});
afterEach(() => {
mockIsDevelopment = false;
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -530,6 +552,28 @@ describe('<Footer />', () => {
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mockIsDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
+3 -1
View File
@@ -62,7 +62,9 @@ export const Footer: React.FC = () => {
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -99,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -11,12 +11,12 @@ exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
"
`;
@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -18,6 +19,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
icon,
color,
marginBottom,
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{text.split('\n').map((line, index) => (
<Text wrap="wrap" key={index}>
<RenderInline text={line} defaultColor={color} />
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
</Text>
))}
</Box>
+6
View File
@@ -48,3 +48,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;
/** Minimum terminal width required to show the full context used label */
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
/** Default context usage fraction at which to trigger compression */
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
+1 -1
View File
@@ -30,7 +30,7 @@ export const INFORMATIVE_TIPS = [
'Choose a specific Gemini model for conversations (/settings)…',
'Limit the number of turns in your session history (/settings)…',
'Automatically summarize large tool outputs to save tokens (settings.json)…',
'Control when chat history gets compressed based on token usage (settings.json)…',
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
'Define custom context file names, like CONTEXT.md (settings.json)…',
'Set max directories to scan for context files (/settings)…',
'Expand your workspace with additional directories (/directory)…',
@@ -50,6 +50,7 @@ import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { theme } from '../semantic-colors.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -2300,14 +2301,14 @@ describe('useGeminiStream', () => {
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
requestTokens: 30,
remainingTokens: 70,
expectedMessage:
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
},
])(
'should add message $name',
@@ -2388,6 +2389,43 @@ describe('useGeminiStream', () => {
});
});
it('should add informational messages when ChatCompressed event is received', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
},
};
})(),
);
const { result } = renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test compression');
});
// Check that the succinct info message was added
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Context compressed from 10% to 5%.',
secondaryText: 'Change threshold in /settings.',
color: theme.status.warning,
}),
expect.any(Number),
);
});
});
it.each([
{
reason: 'STOP',
+25 -14
View File
@@ -108,9 +108,9 @@ enum StreamProcessingStatus {
}
const SUPPRESSED_TOOL_ERRORS_NOTE =
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
const LOW_VERBOSITY_FAILURE_NOTE =
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
@@ -1065,16 +1065,27 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
return addItem({
type: 'info',
text:
`IMPORTANT: This conversation exceeded the compress threshold. ` +
`A compressed context will be sent for future messages (compressed from: ` +
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
});
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
addItem(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1094,12 +1105,12 @@ export const useGeminiStream = (
const limit = tokenLimit(config.getModel());
const isLessThan75Percent =
const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
if (isLessThan75Percent) {
if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
}
@@ -49,7 +49,7 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
errorVerbosity: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
@@ -22,7 +22,7 @@ export interface UseLoadingIndicatorProps {
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[];
errorVerbosity?: 'low' | 'full';
errorVerbosity: 'low' | 'full';
}
export const useLoadingIndicator = ({
@@ -31,7 +31,7 @@ export const useLoadingIndicator = ({
retryStatus,
loadingPhrasesMode,
customWittyPhrases,
errorVerbosity = 'full',
errorVerbosity,
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
+1
View File
@@ -151,6 +151,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
+25 -7
View File
@@ -163,7 +163,6 @@ describe('commandUtils', () => {
it('should return true when query starts with @', () => {
expect(isAtCommand('@file')).toBe(true);
expect(isAtCommand('@path/to/file')).toBe(true);
expect(isAtCommand('@')).toBe(true);
});
it('should return true when query contains @ preceded by whitespace', () => {
@@ -172,17 +171,36 @@ describe('commandUtils', () => {
expect(isAtCommand(' @file')).toBe(true);
});
it('should return false when query does not start with @ and has no spaced @', () => {
it('should return true when @ is preceded by non-whitespace (external editor scenario)', () => {
// When a user composes a prompt in an external editor, @-references may
// appear after punctuation characters such as ':' or '(' without a space.
// The processor must still recognise these as @-commands so that the
// referenced files are pre-loaded before the query is sent to the model.
expect(isAtCommand('check:@file.py')).toBe(true);
expect(isAtCommand('analyze(@file.py)')).toBe(true);
expect(isAtCommand('hello@file')).toBe(true);
expect(isAtCommand('text@path/to/file')).toBe(true);
expect(isAtCommand('user@host')).toBe(true);
});
it('should return false when query does not contain any @<path> pattern', () => {
expect(isAtCommand('file')).toBe(false);
expect(isAtCommand('hello')).toBe(false);
expect(isAtCommand('')).toBe(false);
expect(isAtCommand('email@domain.com')).toBe(false);
expect(isAtCommand('user@host')).toBe(false);
// A bare '@' with no following path characters is not an @-command.
expect(isAtCommand('@')).toBe(false);
});
it('should return false when @ is not preceded by whitespace', () => {
expect(isAtCommand('hello@file')).toBe(false);
expect(isAtCommand('text@path')).toBe(false);
it('should return false when @ is escaped with a backslash', () => {
expect(isAtCommand('\\@file')).toBe(false);
});
it('should return true for multi-line external editor prompts with @-references', () => {
expect(isAtCommand('Please review:\n@src/main.py\nand fix bugs.')).toBe(
true,
);
// @file after a colon on the same line.
expect(isAtCommand('Files:@src/a.py,@src/b.py')).toBe(true);
});
});
+15 -4
View File
@@ -10,18 +10,29 @@ import type { SlashCommand } from '../commands/types.js';
import fs from 'node:fs';
import type { Writable } from 'node:stream';
import type { Settings } from '../../config/settingsSchema.js';
import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
// Pre-compiled regex for detecting @<path> patterns consistent with parseAllAtCommands.
// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
// parseAllAtCommands would find at least one atPath part.
const AT_COMMAND_DETECT_REGEX = new RegExp(
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
);
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
* and followed by a non-whitespace character.
* Returns true if the query contains any '@<path>' pattern that would be
* recognised by the @ command processor, regardless of what character
* precedes the '@' sign. This ensures that prompts written in an external
* editor (where '@' may follow punctuation like ':' or '(') are correctly
* identified and their referenced files pre-loaded before the query is sent
* to the model.
*
* @param query The input query string.
* @returns True if the query looks like an '@' command, false otherwise.
*/
export const isAtCommand = (query: string): boolean =>
// Check if starts with @ OR has a space, then @
query.startsWith('@') || /\s@/.test(query);
AT_COMMAND_DETECT_REGEX.test(query);
/**
* Checks if a query string potentially represents an '/' command.
@@ -12,7 +12,13 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import EventEmitter from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import {
handleAutoUpdate,
setUpdateHandler,
isUpdateInProgress,
waitForUpdateCompletion,
_setUpdateStateForTesting,
} from './handleAutoUpdate.js';
import { MessageType } from '../ui/types.js';
vi.mock('./installationInfo.js', async () => {
@@ -79,6 +85,7 @@ describe('handleAutoUpdate', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
_setUpdateStateForTesting(false);
});
it('should do nothing if update info is null', () => {
@@ -88,6 +95,80 @@ describe('handleAutoUpdate', () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should track update progress state', async () => {
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
updateMessage: 'This is an additional message.',
isGlobal: false,
packageManager: PackageManager.NPM,
});
expect(isUpdateInProgress()).toBe(false);
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(isUpdateInProgress()).toBe(true);
mockChildProcess.emit('close', 0);
expect(isUpdateInProgress()).toBe(false);
});
it('should track update progress state on error', async () => {
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
updateMessage: 'This is an additional message.',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(isUpdateInProgress()).toBe(true);
mockChildProcess.emit('error', new Error('fail'));
expect(isUpdateInProgress()).toBe(false);
});
it('should resolve waitForUpdateCompletion when update succeeds', async () => {
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion();
updateEventEmitter.emit('update-success', {});
await expect(waitPromise).resolves.toBeUndefined();
});
it('should resolve waitForUpdateCompletion when update fails', async () => {
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion();
updateEventEmitter.emit('update-failed', {});
await expect(waitPromise).resolves.toBeUndefined();
});
it('should resolve waitForUpdateCompletion immediately if not in progress', async () => {
_setUpdateStateForTesting(false);
const waitPromise = waitForUpdateCompletion();
await expect(waitPromise).resolves.toBeUndefined();
});
it('should timeout waitForUpdateCompletion', async () => {
vi.useFakeTimers();
_setUpdateStateForTesting(true);
const waitPromise = waitForUpdateCompletion(1000);
vi.advanceTimersByTime(1001);
await expect(waitPromise).resolves.toBeUndefined();
vi.useRealTimers();
});
it('should do nothing if update prompts are disabled', () => {
mockSettings.merged.general.enableAutoUpdateNotification = false;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
@@ -12,6 +12,54 @@ import type { HistoryItem } from '../ui/types.js';
import { MessageType } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
let _updateInProgress = false;
/** @internal */
export function _setUpdateStateForTesting(value: boolean) {
_updateInProgress = value;
}
export function isUpdateInProgress() {
return _updateInProgress;
}
/**
* Returns a promise that resolves when the update process completes or times out.
*/
export async function waitForUpdateCompletion(
timeoutMs = 30000,
): Promise<void> {
if (!_updateInProgress) {
return;
}
debugLogger.log(
'\nGemini CLI is waiting for a background update to complete before restarting...',
);
return new Promise((resolve) => {
// Re-check the condition inside the promise executor to avoid a race condition.
// If the update finished between the initial check and now, resolve immediately.
if (!_updateInProgress) {
resolve();
return;
}
const timer = setTimeout(cleanup, timeoutMs);
function cleanup() {
clearTimeout(timer);
updateEventEmitter.off('update-success', cleanup);
updateEventEmitter.off('update-failed', cleanup);
resolve();
}
updateEventEmitter.once('update-success', cleanup);
updateEventEmitter.once('update-failed', cleanup);
});
}
export function handleAutoUpdate(
info: UpdateObject | null,
@@ -62,6 +110,11 @@ export function handleAutoUpdate(
) {
return;
}
if (_updateInProgress) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
@@ -73,10 +126,14 @@ export function handleAutoUpdate(
shell: true,
detached: true,
});
_updateInProgress = true;
// Un-reference the child process to allow the parent to exit independently.
updateProcess.unref();
updateProcess.on('close', (code) => {
_updateInProgress = false;
if (code === 0) {
updateEventEmitter.emit('update-success', {
message:
@@ -90,6 +147,7 @@ export function handleAutoUpdate(
});
updateProcess.on('error', (err) => {
_updateInProgress = false;
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
});
+9 -1
View File
@@ -7,6 +7,11 @@
import { vi } from 'vitest';
import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as handleAutoUpdate from './handleAutoUpdate.js';
vi.mock('./handleAutoUpdate.js', () => ({
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
}));
describe('processUtils', () => {
const processExit = vi
@@ -14,8 +19,11 @@ describe('processUtils', () => {
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
it('should run cleanup and exit with the relaunch code', async () => {
afterEach(() => vi.clearAllMocks());
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
await relaunchApp();
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
+2
View File
@@ -5,6 +5,7 @@
*/
import { runExitCleanup } from './cleanup.js';
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
/**
* Exit code used to signal that the CLI should be relaunched.
@@ -15,6 +16,7 @@ export const RELAUNCH_EXIT_CODE = 199;
* Exits the process with a special code to signal that the parent process should relaunch it.
*/
export async function relaunchApp(): Promise<void> {
await waitForUpdateCompletion();
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}
@@ -919,6 +919,32 @@ describe('Session Cleanup', () => {
),
);
});
it('should delete the session-specific directory', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1d', // Very short retention to trigger deletion of all but current
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.unlink.mockResolvedValue(undefined);
mockFs.rm.mockResolvedValue(undefined);
await cleanupExpiredSessions(config, settings);
// Verify that fs.rm was called with the session directory for the deleted session that has sessionInfo
// recent456 should be deleted and its directory removed
expect(mockFs.rm).toHaveBeenCalledWith(
path.join('/tmp/test-project', 'recent456'),
expect.objectContaining({ recursive: true, force: true }),
);
});
});
describe('parseRetentionPeriod format validation', () => {
+11
View File
@@ -115,6 +115,17 @@ export async function cleanupExpiredSessions(
} catch {
/* ignore if doesn't exist */
}
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(
config.storage.getProjectTempDir(),
sessionId,
);
try {
await fs.rm(sessionDir, { recursive: true, force: true });
} catch {
/* ignore if doesn't exist */
}
}
if (config.getDebugMode()) {
@@ -735,5 +735,59 @@ describe('SettingsUtils', () => {
expect(result).toBe('false');
});
});
describe('getDisplayValue with units', () => {
it('should format percentage correctly when unit is %', () => {
vi.mocked(getSettingsSchema).mockReturnValue({
model: {
properties: {
compressionThreshold: {
type: 'number',
label: 'Context Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5,
unit: '%',
},
},
},
} as unknown as SettingsSchemaType);
const settings = makeMockSettings({
model: { compressionThreshold: 0.8 },
});
const result = getDisplayValue(
'model.compressionThreshold',
settings,
makeMockSettings({}),
);
expect(result).toBe('0.8 (80%)*');
});
it('should append unit for non-% units', () => {
vi.mocked(getSettingsSchema).mockReturnValue({
ui: {
properties: {
pollingInterval: {
type: 'number',
label: 'Polling Interval',
category: 'UI',
requiresRestart: false,
default: 60,
unit: 's',
},
},
},
} as unknown as SettingsSchemaType);
const settings = makeMockSettings({ ui: { pollingInterval: 30 } });
const result = getDisplayValue(
'ui.pollingInterval',
settings,
makeMockSettings({}),
);
expect(result).toBe('30s*');
});
});
});
});
+6 -1
View File
@@ -84,7 +84,7 @@ export function getDefaultValue(key: string): SettingsValue {
/**
* Get the effective default value for a setting, checking experiment values when available.
* For settings like compressionThreshold, this will return the experiment value if set,
* For settings like Context Compression Threshold, this will return the experiment value if set,
* otherwise falls back to the schema default.
*/
export function getEffectiveDefaultValue(
@@ -289,6 +289,11 @@ export function getDisplayValue(
valueString = option?.label ?? `${value}`;
}
if (definition?.unit === '%' && typeof value === 'number') {
valueString = `${value} (${Math.round(value * 100)}%)`;
} else if (definition?.unit) {
valueString = `${valueString}${definition.unit}`;
}
if (existsInScope) {
return `${valueString}*`;
}
+61
View File
@@ -10,6 +10,7 @@ import {
extractIdsFromResponse,
isTerminalState,
A2AResultReassembler,
AUTH_REQUIRED_MSG,
} from './a2aUtils.js';
import type { SendMessageResult } from './a2a-client-manager.js';
import type {
@@ -285,6 +286,66 @@ describe('a2aUtils', () => {
);
});
it('should handle auth-required state with a message', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
status: {
state: 'auth-required',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'I need your permission.' }],
} as Message,
},
} as unknown as SendMessageResult);
expect(reassembler.toString()).toContain('I need your permission.');
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
});
it('should handle auth-required state without relying on metadata', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
status: {
state: 'auth-required',
},
} as unknown as SendMessageResult);
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
});
it('should not duplicate the auth instruction OR agent message if multiple identical auth-required chunks arrive', () => {
const reassembler = new A2AResultReassembler();
const chunk = {
kind: 'status-update',
status: {
state: 'auth-required',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'You need to login here.' }],
} as Message,
},
} as unknown as SendMessageResult;
reassembler.update(chunk);
// Simulate multiple updates with the same overall state
reassembler.update(chunk);
reassembler.update(chunk);
const output = reassembler.toString();
// The substring should only appear exactly once
expect(output.split(AUTH_REQUIRED_MSG).length - 1).toBe(1);
// Crucially, the agent's actual custom message should ALSO only appear exactly once
expect(output.split('You need to login here.').length - 1).toBe(1);
});
it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => {
const reassembler = new A2AResultReassembler();
+15
View File
@@ -16,6 +16,8 @@ import type {
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
/**
* Reassembles incremental A2A streaming updates into a coherent result.
* Shows sequential status/messages followed by all reassembled artifacts.
@@ -33,6 +35,7 @@ export class A2AResultReassembler {
switch (chunk.kind) {
case 'status-update':
this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
break;
@@ -65,6 +68,7 @@ export class A2AResultReassembler {
break;
case 'task':
this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
if (chunk.artifacts) {
for (const art of chunk.artifacts) {
@@ -106,6 +110,17 @@ export class A2AResultReassembler {
}
}
private appendStateInstructions(state: TaskState | undefined) {
if (state !== 'auth-required') {
return;
}
// Prevent duplicate instructions if multiple chunks report auth-required
if (!this.messageLog.includes(AUTH_REQUIRED_MSG)) {
this.messageLog.push(AUTH_REQUIRED_MSG);
}
}
private pushMessage(message: Message | undefined) {
if (!message) return;
const text = extractPartsText(message.parts, '\n');
+11 -3
View File
@@ -508,6 +508,16 @@ export class CodeAssistServer implements ContentGenerator {
}
interface VpcScErrorResponse {
response?: {
data?: {
error?: {
details?: unknown[];
};
};
};
}
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse & {
response: {
data: {
error: {
@@ -515,9 +525,7 @@ interface VpcScErrorResponse {
};
};
};
}
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse {
} {
return (
!!error &&
typeof error === 'object' &&
+24 -4
View File
@@ -2950,9 +2950,11 @@ describe('Plans Directory Initialization', () => {
afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.promises.access).mockRestore?.();
});
it('should create plans directory and add it to workspace context when plan is enabled', async () => {
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -2961,14 +2963,32 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
const config = new Config({
...baseParams,
+18 -2
View File
@@ -339,6 +339,15 @@ export interface GeminiCLIExtension {
* Safety checkers contributed by this extension.
*/
checkers?: SafetyCheckerRule[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}
export interface ExtensionInstallMetadata {
@@ -1104,8 +1113,15 @@ export class Config implements McpContext {
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
await fs.promises.mkdir(plansDir, { recursive: true });
this.workspaceContext.addDirectory(plansDir);
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}
// Initialize centralized FileDiscoveryService
+10 -1
View File
@@ -72,7 +72,16 @@ priority = 70
modes = ["plan"]
[[rule]]
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
toolName = [
"glob",
"grep_search",
"list_directory",
"read_file",
"google_web_search",
"activate_skill",
"codebase_investigator",
"cli_help"
]
decision = "allow"
priority = 70
modes = ["plan"]
@@ -1593,7 +1593,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.PLAN],
},
{
toolName: 'codebase_investigator',
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
},
@@ -1605,7 +1605,7 @@ describe('PolicyEngine', () => {
});
const fixedResult = await fixedEngine.check(
{ name: 'codebase_investigator' },
{ name: 'unknown_subagent' },
undefined,
);
+24 -5
View File
@@ -909,7 +909,7 @@ priority = 100
}
});
it('should override default subagent rules when in Plan Mode', async () => {
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
const tempPolicyDir = await fs.mkdtemp(
@@ -931,9 +931,9 @@ priority = 100
approvalMode: ApprovalMode.PLAN,
});
// 3. Simulate a Subagent being registered (Dynamic Rule)
// 3. Simulate an unknown Subagent being registered (Dynamic Rule)
engine.addRule({
toolName: 'codebase_investigator',
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'AgentRegistry (Dynamic)',
@@ -942,13 +942,13 @@ priority = 100
// 4. Verify Behavior:
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
const checkResult = await engine.check(
{ name: 'codebase_investigator' },
{ name: 'unknown_subagent' },
undefined,
);
expect(
checkResult.decision,
'Subagent should be DENIED in Plan Mode',
'Unknown subagent should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY);
// 5. Verify Explicit Allows still work
@@ -958,6 +958,25 @@ priority = 100
readResult.decision,
'Explicitly allowed tools (read_file) should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
// 6. Verify Built-in Research Subagents are ALLOWED
const codebaseResult = await engine.check(
{ name: 'codebase_investigator' },
undefined,
);
expect(
codebaseResult.decision,
'codebase_investigator should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
const cliHelpResult = await engine.check(
{ name: 'cli_help' },
undefined,
);
expect(
cliHelpResult.decision,
'cli_help should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
} finally {
await fs.rm(tempPolicyDir, { recursive: true, force: true });
}
@@ -309,23 +309,33 @@ describe('ChatRecordingService', () => {
});
describe('deleteSession', () => {
it('should delete the session file and tool outputs if they exist', () => {
it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
const sessionId = 'test-session-id';
const chatsDir = path.join(testTempDir, 'chats');
const logsDir = path.join(testTempDir, 'logs');
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
const sessionDir = path.join(testTempDir, sessionId);
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'test-session-id.json');
fs.mkdirSync(logsDir, { recursive: true });
fs.mkdirSync(toolOutputsDir, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
const sessionFile = path.join(chatsDir, `${sessionId}.json`);
fs.writeFileSync(sessionFile, '{}');
const toolOutputDir = path.join(
testTempDir,
'tool-outputs',
'session-test-session-id',
);
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
fs.writeFileSync(logFile, '{}');
const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
fs.mkdirSync(toolOutputDir, { recursive: true });
chatRecordingService.deleteSession('test-session-id');
chatRecordingService.deleteSession(sessionId);
expect(fs.existsSync(sessionFile)).toBe(false);
expect(fs.existsSync(logFile)).toBe(false);
expect(fs.existsSync(toolOutputDir)).toBe(false);
expect(fs.existsSync(sessionDir)).toBe(false);
});
it('should not throw if session file does not exist', () => {
@@ -569,6 +569,13 @@ export class ChatRecordingService {
fs.unlinkSync(sessionPath);
}
// Cleanup Activity logs in the project logs directory
const logsDir = path.join(tempDir, 'logs');
const logPath = path.join(logsDir, `session-${sessionId}.jsonl`);
if (fs.existsSync(logPath)) {
fs.unlinkSync(logPath);
}
// Cleanup tool outputs for this session
const safeSessionId = sanitizeFilenamePart(sessionId);
const toolOutputDir = path.join(
@@ -585,6 +592,13 @@ export class ChatRecordingService {
) {
fs.rmSync(toolOutputDir, { recursive: true, force: true });
}
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
const sessionDir = path.join(tempDir, safeSessionId);
// Robustness: Ensure the path is strictly within the temp root
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
} catch (error) {
debugLogger.error('Error deleting session file.', error);
throw error;