Compare commits

...

13 Commits

Author SHA1 Message Date
Christine Betts 675b3d7834 test docs change 2026-02-02 17:20:03 -05:00
Jack Wotherspoon ae672881d1 docs: update clearContext to hookSpecificOutput (#18024) 2026-02-02 17:32:37 +00:00
Jerop Kipruto 9e7c10ad88 feat(plan): use placeholder for choice question "Other" option (#18101) 2026-02-02 17:00:13 +00:00
Victor Vianna 381669dce0 chore: delete autoAccept setting unused in production (#17862)
Co-authored-by: Victor Hugo Vianna Silva <victorvianna@google.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-02 13:49:17 +00:00
Bryan Morgan 707b3e85d5 fix(ci): prevent stale PR closer from incorrectly closing new PRs (#18069) 2026-02-01 15:13:29 -05:00
Bryan Morgan 7d36cc004f feat(acp): add session resume support (#18043)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-01 19:16:57 +00:00
Dmitry Lyalin cb4f0c6fa4 Fix Esc cancel during streaming (#18039) 2026-02-01 16:16:14 +00:00
Bryan Morgan b0f38104d7 fix(workflow): update maintainer check logic to be inclusive and case-insensitive (#18009) 2026-01-30 22:32:58 -05:00
Jacob Richman 7469ea0fca Fix truncation for AskQuestion (#18001) 2026-01-31 01:07:41 +00:00
Jacob Richman 00fdb30211 Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over AppContainer for input handling. (#17993) 2026-01-31 00:11:14 +00:00
g-samroberts 0fe8492569 Fix broken link. (#17972) 2026-01-30 21:31:07 +00:00
Abhi 76387d22ae fix(core): ensure YOLO mode auto-approves complex shell commands when parsing fails (#17920) 2026-01-30 20:44:17 +00:00
Sehoon Shon a362b7b382 lower the default max retries to reduce contention (#17975) 2026-01-30 20:00:31 +00:00
35 changed files with 1327 additions and 417 deletions
@@ -43,19 +43,23 @@ jobs:
// 1. Fetch maintainers for verification
let maintainerLogins = new Set();
let teamFetchSucceeded = false;
try {
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
org: context.repo.owner,
team_slug: 'gemini-cli-maintainers'
});
maintainerLogins = new Set(members.map(m => m.login));
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
teamFetchSucceeded = true;
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
} catch (e) {
core.warning('Failed to fetch team members');
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
}
const isMaintainer = (login, assoc) => {
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
const isTeamMember = maintainerLogins.has(login.toLowerCase());
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
return isTeamMember || isRepoMaintainer;
};
// 2. Determine which PRs to check
@@ -153,7 +157,17 @@ jobs:
const labels = pr.labels.map(l => l.name.toLowerCase());
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
let lastActivity = new Date(0);
// 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) {
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
continue;
}
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
let lastActivity = new Date(pr.created_at);
try {
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner,
@@ -177,8 +191,12 @@ jobs:
if (d > lastActivity) lastActivity = d;
}
}
} catch (e) {}
} catch (e) {
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
}
// For maintainer PRs, the PR creation itself counts as maintainer activity.
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
if (maintainerPr) {
const d = new Date(pr.created_at);
if (d > lastActivity) lastActivity = d;
-1
View File
@@ -97,7 +97,6 @@ they appear in the UI.
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
+1 -1
View File
@@ -21,7 +21,7 @@ the full instructions and resources required to complete the task using the
## Key Benefits
- **Shared Expertise:** Package complex workflows (like a specific team's PR
- **Shared Expertise:** Package complex workflows (like a a specific team's PR
review process) into a folder that anyone can use.
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
consistently by providing a procedural framework.
-8
View File
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
MCP servers of their own. This should not be used as an airtight security
mechanism.
- **`autoAccept`** (boolean):
- **Description:** Controls whether the CLI automatically accepts and executes
tool calls that are considered safe (e.g., read-only operations) without
explicit user confirmation. If set to `true`, the CLI will bypass the
confirmation prompt for tools deemed safe.
- **Default:** `false`
- **Example:** `"autoAccept": true`
- **`theme`** (string):
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
- **Default:** `"Default"`
-5
View File
@@ -661,11 +661,6 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.autoAccept`** (boolean):
- **Description:** Automatically accept and execute tool calls that are
considered safe (e.g., read-only operations).
- **Default:** `false`
- **`tools.approvalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
+2 -2
View File
@@ -167,8 +167,8 @@ case is response validation and automatic retries.
- `reason`: Required if denied. This text is sent **to the agent as a new
prompt** to request a correction.
- `continue`: Set to `false` to **stop the session** without retrying.
- `clearContext`: If `true`, clears conversation history (LLM memory) while
preserving UI display.
- `hookSpecificOutput.clearContext`: If `true`, clears conversation history
(LLM memory) while preserving UI display.
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
turn using `stderr` as the feedback prompt.
+7 -1
View File
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
it('rejects invalid shell expressions', async () => {
await rig.setup('rejects invalid shell expressions', {
settings: { tools: { core: ['run_shell_command'] } },
settings: {
tools: {
core: ['run_shell_command'],
allowed: ['run_shell_command(echo)'], // Specifically allow echo
},
},
});
const invalidCommand = getInvalidCommand();
const result = await rig.run({
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
});
expect(result).toContain('FAIL');
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
it('should handle complex mixed configurations', async () => {
const settings: Settings = {
tools: {
autoAccept: true, // Allows read-only tools
allowed: ['custom-tool', 'my-server__special-tool'],
exclude: ['glob', 'dangerous-tool'],
},
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
it('should verify priority ordering works correctly in practice', async () => {
const settings: Settings = {
tools: {
autoAccept: true, // Priority 50
allowed: ['specific-tool'], // Priority 100
exclude: ['blocked-tool'], // Priority 200
},
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
it('should verify rules are created with correct priorities', async () => {
const settings: Settings = {
tools: {
autoAccept: true,
allowed: ['tool1', 'tool2'],
exclude: ['tool3'],
},
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
},
tools: {
sandbox: 'inherit',
autoAccept: false,
},
};
-11
View File
@@ -1041,17 +1041,6 @@ const SETTINGS_SCHEMA = {
},
},
},
autoAccept: {
type: 'boolean',
label: 'Auto Accept',
category: 'Tools',
requiresRestart: false,
default: false,
description: oneLine`
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
`,
showInDialog: true,
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
+62 -22
View File
@@ -371,7 +371,9 @@ describe('AppContainer State Management', () => {
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
// Add other properties if AppContainer uses them
lines: [''],
cursor: [0, 0],
handleInput: vi.fn().mockReturnValue(false),
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
@@ -1900,7 +1902,7 @@ describe('AppContainer State Management', () => {
});
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
let handleGlobalKeypress: (key: Key) => void;
let handleGlobalKeypress: (key: Key) => boolean;
let mockHandleSlashCommand: Mock;
let mockCancelOngoingRequest: Mock;
let rerender: () => void;
@@ -1935,9 +1937,11 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
// Capture the keypress handler from the AppContainer
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
handleGlobalKeypress = callback;
});
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
// Mock slash command handler
mockHandleSlashCommand = vi.fn();
@@ -1961,6 +1965,9 @@ describe('AppContainer State Management', () => {
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
lines: [''],
cursor: [0, 0],
handleInput: vi.fn().mockReturnValue(false),
});
vi.useFakeTimers();
@@ -2020,19 +2027,6 @@ describe('AppContainer State Management', () => {
});
describe('CTRL+D', () => {
it('should do nothing if text buffer is not empty', async () => {
mockedUseTextBuffer.mockReturnValue({
text: 'some text',
setText: vi.fn(),
});
await setupKeypressTest();
pressKey({ name: 'd', ctrl: true }, 2);
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
unmount();
});
it('should quit on second press if buffer is empty', async () => {
await setupKeypressTest();
@@ -2047,6 +2041,50 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
mockedUseTextBuffer.mockReturnValue({
text: 'some text',
setText: vi.fn(),
lines: ['some text'],
cursor: [0, 9], // At the end
handleInput: vi.fn().mockReturnValue(false),
});
await setupKeypressTest();
// Capture return value
let result = true;
const originalPressKey = (key: Partial<Key>) => {
act(() => {
result = handleGlobalKeypress({
name: 'd',
shift: false,
alt: false,
ctrl: true,
cmd: false,
...key,
} as Key);
});
rerender();
};
originalPressKey({ name: 'd', ctrl: true });
// AppContainer's handler should return true if it reaches it
expect(result).toBe(true);
// But it should only be called once, so count is 1, not quitting yet.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
originalPressKey({ name: 'd', ctrl: true });
// Now count is 2, it should quit.
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
unmount();
});
it('should reset press count after a timeout', async () => {
await setupKeypressTest();
@@ -2066,7 +2104,7 @@ describe('AppContainer State Management', () => {
});
describe('Copy Mode (CTRL+S)', () => {
let handleGlobalKeypress: (key: Key) => void;
let handleGlobalKeypress: (key: Key) => boolean;
let rerender: () => void;
let unmount: () => void;
@@ -2096,9 +2134,11 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
handleGlobalKeypress = callback;
});
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
vi.useFakeTimers();
});
+15 -13
View File
@@ -532,6 +532,14 @@ export const AppContainer = (props: AppContainerProps) => {
shellModeActive,
getPreferredEditor,
});
const bufferRef = useRef(buffer);
useEffect(() => {
bufferRef.current = buffer;
}, [buffer]);
const stableSetText = useCallback((text: string) => {
bufferRef.current.setText(text);
}, []);
// Initialize input history from logger (past sessions)
useEffect(() => {
@@ -826,7 +834,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
},
setText: (text: string) => buffer.setText(text),
setText: stableSetText,
}),
[
setAuthState,
@@ -844,7 +852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
stableSetText,
],
);
@@ -1405,7 +1413,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (ctrlCPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else {
} else if (ctrlCPressCount > 0) {
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0);
ctrlCTimerRef.current = null;
@@ -1424,7 +1432,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (ctrlDPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else {
} else if (ctrlDPressCount > 0) {
ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0);
ctrlDTimerRef.current = null;
@@ -1465,7 +1473,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
const handleGlobalKeypress = useCallback(
(key: Key) => {
(key: Key): boolean => {
if (copyModeEnabled) {
setCopyModeEnabled(false);
enableMouseEvents();
@@ -1492,9 +1500,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCtrlCPressCount((prev) => prev + 1);
return true;
} else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) {
return false;
}
setCtrlDPressCount((prev) => prev + 1);
return true;
}
@@ -1538,9 +1543,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId ||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
buffer.text.length === 0
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
@@ -1625,7 +1628,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
ideContextState,
setCtrlCPressCount,
buffer.text.length,
setCtrlDPressCount,
handleSlashCommand,
cancelOngoingRequest,
@@ -1647,7 +1649,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(handleGlobalKeypress, { isActive: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Helper to write to stdin with proper act() wrapping
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
@@ -42,7 +43,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -108,7 +108,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -129,7 +128,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -159,33 +157,49 @@ describe('AskUserDialog', () => {
});
});
it('shows scroll arrows when options exceed available height', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
describe.each([
{ useAlternateBuffer: true, expectedArrows: false },
{ useAlternateBuffer: false, expectedArrows: true },
])(
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
({ useAlternateBuffer, expectedArrows }) => {
it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
);
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
{ useAlternateBuffer },
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
await waitFor(() => {
if (expectedArrows) {
expect(lastFrame()).toContain('▲');
expect(lastFrame()).toContain('▼');
} else {
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
}
expect(lastFrame()).toMatchSnapshot();
});
});
},
);
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
@@ -194,7 +208,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -246,7 +259,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -261,7 +273,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -276,7 +287,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -308,7 +318,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -351,7 +360,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -420,7 +428,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -450,7 +457,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -496,7 +502,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -533,7 +538,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -567,7 +571,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -590,7 +593,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -613,7 +615,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -649,7 +650,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -681,7 +681,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -729,7 +728,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -780,7 +778,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -807,7 +804,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={onCancel}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -854,7 +850,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -914,7 +909,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -946,4 +940,139 @@ describe('AskUserDialog', () => {
});
});
});
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Context Test',
options: Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5, // Small height to force scroll arrows
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: false },
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
expect(lastFrame()).toContain('▲');
expect(lastFrame()).toContain('▼');
});
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
const longQuestion =
'This is a very long question ' + 'with many words '.repeat(10);
const questions: Question[] = [
{
question: longQuestion,
header: 'Alternate Buffer Test',
options: [{ label: 'Option 1', description: 'Desc 1' }],
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5,
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={40} // Small width to force wrapping
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: true },
);
// Should NOT contain the truncation message
expect(lastFrame()).not.toContain('hidden ...');
// Should contain the full long question (or at least its parts)
expect(lastFrame()).toContain('This is a very long question');
});
describe('Choice question placeholder', () => {
it('uses placeholder for "Other" option when provided', async () => {
const questions: Question[] = [
{
question: 'Select your preferred language:',
header: 'Language',
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
],
placeholder: 'Type another language...',
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>,
{ width: 80 },
);
// Navigate to the "Other" option
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('uses default placeholder when not provided', async () => {
const questions: Question[] = [
{
question: 'Select your preferred language:',
header: 'Language',
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>,
{ width: 80 },
);
// Navigate to the "Other" option
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
});
});
@@ -5,7 +5,14 @@
*/
import type React from 'react';
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
@@ -21,6 +28,8 @@ import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface AskUserDialogState {
answers: { [key: string]: string };
@@ -121,7 +130,7 @@ interface AskUserDialogProps {
/**
* Height constraint for scrollable content.
*/
availableHeight: number;
availableHeight?: number;
}
interface ReviewViewProps {
@@ -199,7 +208,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
availableHeight?: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -216,6 +225,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor
const bufferWidth =
@@ -279,13 +289,20 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const INPUT_HEIGHT = 2; // TextInput + margin
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
const questionHeight = Math.max(1, availableHeight - overhead);
const questionHeight =
availableHeight && !isAlternateBuffer
? Math.max(1, availableHeight - overhead)
: undefined;
return (
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
@@ -389,7 +406,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
availableHeight?: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -406,6 +423,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const isAlternateBuffer = useAlternateBuffer();
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
@@ -711,18 +729,27 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = Math.max(1, availableHeight - overhead);
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
const maxItemsToShow = Math.max(
1,
Math.floor((listHeight - questionHeight) / 2),
);
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - 4))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
: selectionItems.length;
return (
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<Text bold color={theme.text.primary}>
{question.question}
{question.multiSelect && (
@@ -753,7 +780,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
// Render inline text input for custom option
if (optionItem.type === 'other') {
const placeholder = 'Enter a custom value';
const placeholder = question.placeholder || 'Enter a custom value';
return (
<Box flexDirection="row">
{showCheck && (
@@ -824,8 +851,15 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onCancel,
onActiveTextInputChange,
width,
availableHeight,
availableHeight: availableHeightProp,
}) => {
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
(uiState?.constrainHeight !== false
? uiState?.availableTerminalHeight
: undefined);
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state;
@@ -45,6 +45,8 @@ import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -169,7 +171,16 @@ describe('InputPrompt', () => {
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn(),
handleInput: vi.fn((key: Key) => {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
}
return false;
}
return false;
}),
move: vi.fn(),
moveToOffset: vi.fn((offset: number) => {
mockBuffer.cursor = [0, offset];
@@ -499,6 +510,23 @@ describe('InputPrompt', () => {
unmount();
});
it('should clear the buffer and reset completion on Ctrl+C', async () => {
mockBuffer.text = 'some text';
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\u0003'); // Ctrl+C
});
await waitFor(() => {
expect(mockBuffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
});
unmount();
});
describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+36 -26
View File
@@ -482,6 +482,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false;
}
if (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation)
) {
return false;
}
if (key.name === 'paste') {
// Record paste time to prevent accidental auto-submission
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
@@ -604,6 +612,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
@@ -611,12 +625,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return true;
}
if (reverseSearchActive || commandSearchActive) {
const isCommandSearch = commandSearchActive;
@@ -881,14 +889,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.move('end');
return true;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return false;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
@@ -933,17 +933,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
const handled = buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.cmd
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
if (handled) {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
resetCompletionState();
}
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.cmd
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
}
return handled;
},
@@ -979,10 +985,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundShells.size,
backgroundShellHeight,
history,
streamingState,
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused,
priority: true,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
},
tools: {
enableInteractiveShell: true,
autoAccept: true,
useRipgrep: true,
},
security: {
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
userSettings: {
tools: {
enableInteractiveShell: true,
autoAccept: false,
useRipgrep: true,
truncateToolOutputThreshold: 25000,
truncateToolOutputLines: 500,
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
},
tools: {
enableInteractiveShell: false,
autoAccept: false,
useRipgrep: false,
},
security: {
@@ -1,5 +1,76 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 1`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Enter a custom value
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Type another language...
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?
@@ -104,19 +175,6 @@ Which database should we use?
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"← □ License │ □ README │ ≡ Review →
@@ -271,7 +271,7 @@ export const ToolConfirmationMessage: React.FC<
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? 10}
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
@@ -1515,6 +1515,50 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).text).toBe('');
});
it('should handle CLEAR_INPUT (Ctrl+C)', () => {
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'hello',
viewport,
isValidPath: () => false,
}),
);
expect(getBufferState(result).text).toBe('hello');
let handled = false;
act(() => {
handled = result.current.handleInput({
name: 'c',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\u0003',
});
});
expect(handled).toBe(true);
expect(getBufferState(result).text).toBe('');
});
it('should NOT handle CLEAR_INPUT if buffer is empty', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
let handled = true;
act(() => {
handled = result.current.handleInput({
name: 'c',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\u0003',
});
});
expect(handled).toBe(false);
});
it('should handle "Backspace" key', () => {
const { result } = renderHook(() =>
useTextBuffer({
@@ -2930,6 +2930,13 @@ export function useTextBuffer({
move('end');
return true;
}
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (text.length > 0) {
setText('');
return true;
}
return false;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
deleteWordLeft();
return true;
@@ -2943,6 +2950,13 @@ export function useTextBuffer({
return true;
}
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
const lastLineIdx = lines.length - 1;
if (
cursorRow === lastLineIdx &&
cursorCol === cpLen(lines[lastLineIdx] ?? '')
) {
return false;
}
del();
return true;
}
@@ -2974,6 +2988,8 @@ export function useTextBuffer({
cursorCol,
lines,
singleLine,
setText,
text,
],
);
@@ -24,7 +24,14 @@ import { coreEvents } from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
vi.mock('path');
vi.mock('../../utils/sessionUtils.js');
vi.mock('../../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/sessionUtils.js')>();
return {
...actual,
getSessionFiles: vi.fn(),
};
});
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
+5 -191
View File
@@ -13,11 +13,12 @@ import type {
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
import { coreEvents } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { MessageType, ToolCallStatus } from '../types.js';
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
export { convertSessionToHistoryFormats };
export const useSessionBrowser = (
config: Config,
@@ -112,190 +113,3 @@ export const useSessionBrowser = (
),
};
};
/**
* Converts session/conversation data into UI history and Gemini client history formats.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
messageType = MessageType.USER;
break;
case 'info':
messageType = MessageType.INFO;
break;
case 'error':
messageType = MessageType.ERROR;
break;
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: uiText,
});
}
// Add tool calls if present
if (
msg.type !== 'user' &&
'toolCalls' in msg &&
msg.toolCalls &&
msg.toolCalls.length > 0
) {
uiHistory.push({
type: 'tool_group',
tools: msg.toolCalls.map((tool) => ({
callId: tool.id,
name: tool.displayName || tool.name,
description: tool.description || '',
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
status:
tool.status === 'success'
? ToolCallStatus.Success
: ToolCallStatus.Error,
resultDisplay: tool.resultDisplay,
confirmationDetails: undefined,
})),
});
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
+194
View File
@@ -16,6 +16,13 @@ import {
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
import type { Part } from '@google/genai';
import { checkExhaustive } from './checks.js';
import {
MessageType,
ToolCallStatus,
type HistoryItemWithoutId,
} from '../ui/types.js';
/**
* Constant for the resume "latest" identifier.
@@ -514,3 +521,190 @@ export class SessionSelector {
}
}
}
/**
* Converts session/conversation data into UI history and Gemini client history formats.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
messageType = MessageType.USER;
break;
case 'info':
messageType = MessageType.INFO;
break;
case 'error':
messageType = MessageType.ERROR;
break;
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: uiText,
});
}
// Add tool calls if present
if (
msg.type !== 'user' &&
'toolCalls' in msg &&
msg.toolCalls &&
msg.toolCalls.length > 0
) {
uiHistory.push({
type: 'tool_group',
tools: msg.toolCalls.map((tool) => ({
callId: tool.id,
name: tool.displayName || tool.name,
description: tool.description || '',
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
status:
tool.status === 'success'
? ToolCallStatus.Success
: ToolCallStatus.Error,
resultDisplay: tool.resultDisplay,
confirmationDetails: undefined,
})),
});
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
@@ -0,0 +1,224 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import * as acp from '@agentclientprotocol/sdk';
import { AuthType, type Config } from '@google/gemini-cli-core';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/sessionUtils.js')>();
return {
...actual,
SessionSelector: vi.fn(),
convertSessionToHistoryFormats: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
resumeChat: vi.fn().mockResolvedValue(undefined),
getChat: vi.fn().mockReturnValue({}),
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should advertise loadSession capability', async () => {
const response = await agent.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should load a session, resume chat, and stream all message types', async () => {
const sessionId = 'existing-session-id';
const sessionData = {
sessionId,
messages: [
{ type: 'user', content: [{ text: 'Hello' }] },
{
type: 'gemini',
content: [{ text: 'Hi there' }],
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
toolCalls: [
{
id: 'call-1',
name: 'test_tool',
displayName: 'Test Tool',
status: 'success',
resultDisplay: 'Tool output',
},
],
},
{
type: 'gemini',
content: [{ text: 'Trying a write' }],
toolCalls: [
{
id: 'call-2',
name: 'write_file',
displayName: 'Write File',
status: 'error',
resultDisplay: 'Permission denied',
},
],
},
],
};
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
});
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData,
sessionPath: '/path/to/session.json',
}),
}));
const mockClientHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
clientHistory: mockClientHistory,
uiHistory: [],
});
const response = await agent.loadSession({
sessionId,
cwd: '/tmp',
mcpServers: [],
});
expect(response).toEqual({});
// Verify resumeChat received the correct arguments
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
mockClientHistory,
expect.objectContaining({
conversation: sessionData,
filePath: '/path/to/session.json',
}),
);
await vi.waitFor(() => {
// User message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'user_message_chunk',
content: expect.objectContaining({ text: 'Hello' }),
}),
}),
);
// Agent thought
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: expect.objectContaining({
text: '**Thinking**\nabout greeting',
}),
}),
}),
);
// Agent message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({ text: 'Hi there' }),
}),
}),
);
// Successful tool call → 'completed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-1',
status: 'completed',
title: 'Test Tool',
kind: 'read',
content: [
{
type: 'content',
content: { type: 'text', text: 'Tool output' },
},
],
}),
}),
);
// Failed tool call → 'failed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
}),
}),
);
});
});
});
@@ -129,7 +129,7 @@ describe('GeminiAgent', () => {
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
expect(response.agentCapabilities?.loadSession).toBe(false);
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should authenticate correctly', async () => {
@@ -273,6 +273,7 @@ describe('Session', () => {
mockChat = {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
recordCompletedToolCalls: vi.fn(),
} as unknown as Mocked<GeminiChat>;
mockTool = {
kind: 'native',
@@ -293,6 +294,7 @@ describe('Session', () => {
} as unknown as Mocked<MessageBus>;
mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getPreviewFeatures: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getFileService: vi.fn().mockReturnValue({
@@ -10,6 +10,7 @@ import type {
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
AuthType,
@@ -32,6 +33,7 @@ import {
createWorkingStdio,
startupProfiler,
Kind,
partListUnionToString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -47,6 +49,10 @@ import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
export async function runZedIntegration(
config: Config,
@@ -107,7 +113,7 @@ export class GeminiAgent {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentCapabilities: {
loadSession: false,
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
@@ -146,23 +152,11 @@ export class GeminiAgent {
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
if (this.settings.merged.security.auth.selectedType) {
try {
await config.refreshAuth(
this.settings.merged.security.auth.selectedType,
);
isAuthenticated = true;
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
}
}
if (!isAuthenticated) {
throw acp.RequestError.authRequired();
}
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
@@ -184,6 +178,88 @@ export class GeminiAgent {
};
}
async loadSession({
sessionId,
cwd,
mcpServers,
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
);
const sessionSelector = new SessionSelector(config);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const { clientHistory } = convertSessionToHistoryFormats(
sessionData.messages,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
await geminiClient.resumeChat(clientHistory, {
conversation: sessionData,
filePath: sessionPath,
});
const session = new Session(
sessionId,
geminiClient.getChat(),
config,
this.connection,
);
this.sessions.set(sessionId, session);
// Stream history back to client
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {};
}
private async initializeSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
): Promise<Config> {
const selectedAuthType = this.settings.merged.security.auth.selectedType;
if (!selectedAuthType) {
throw acp.RequestError.authRequired();
}
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
// 3. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
return config;
}
async newSessionConfig(
sessionId: string,
cwd: string,
@@ -228,8 +304,6 @@ export class GeminiAgent {
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
await config.initialize();
startupProfiler.flush(config);
return config;
}
@@ -269,6 +343,73 @@ export class Session {
this.pendingPrompt = null;
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
if (msg.type === 'user') {
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: contentString },
});
}
} else if (msg.type === 'gemini') {
// Thoughts
if (msg.thoughts) {
for (const thought of msg.thoughts) {
const thoughtText = `**${thought.subject}**\n${thought.description}`;
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: thoughtText },
});
}
}
// Message text
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: contentString },
});
}
// Tool calls
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
const toolCallContent: acp.ToolCallContent[] = [];
if (toolCall.resultDisplay) {
if (typeof toolCall.resultDisplay === 'string') {
toolCallContent.push({
type: 'content',
content: { type: 'text', text: toolCall.resultDisplay },
});
} else if ('fileName' in toolCall.resultDisplay) {
toolCallContent.push({
type: 'diff',
path: toolCall.resultDisplay.fileName,
oldText: toolCall.resultDisplay.originalContent,
newText: toolCall.resultDisplay.newContent,
});
}
}
const tool = this.config.getToolRegistry().getTool(toolCall.name);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: toolCall.id,
status: toolCall.status === 'success' ? 'completed' : 'failed',
title: toolCall.displayName || toolCall.name,
content: toolCallContent,
kind: tool ? toAcpToolKind(tool.kind) : 'other',
});
}
}
}
}
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
@@ -533,6 +674,33 @@ export class Session {
),
);
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: 'success',
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
invocation,
response: {
callId,
responseParts: convertToFunctionResponse(
fc.name,
callId,
toolResult.llmContent,
this.config.getActiveModel(),
),
resultDisplay: toolResult.returnDisplay,
error: undefined,
errorType: undefined,
},
},
]);
return convertToFunctionResponse(
fc.name,
callId,
@@ -551,6 +719,35 @@ export class Session {
],
});
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: 'error',
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
response: {
callId,
responseParts: [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: undefined,
},
},
]);
return errorResponse(error);
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ export interface Question {
options?: QuestionOption[];
/** Allow multiple selections. Only applies when type='choice'. */
multiSelect?: boolean;
/** Placeholder hint text. Only applies when type='text'. */
/** Placeholder hint text. For type='text', shown in the input field. For type='choice', shown in the "Other" custom input. */
placeholder?: string;
}
@@ -1398,6 +1398,100 @@ describe('PolicyEngine', () => {
});
});
describe('shell command parsing failure', () => {
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
decision: PolicyDecision.ALLOW,
priority: 999,
modes: [ApprovalMode.YOLO],
},
{
toolName: 'run_shell_command',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
// Simulate parsing failure (splitCommands returning empty array)
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
expect(result.rule).toBeDefined();
expect(result.rule?.priority).toBe(999);
});
it('should return DENY in YOLO mode if shell command parsing fails and a higher priority rule says DENY', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.DENY,
priority: 2000, // Very high priority DENY (e.g. Admin)
},
{
decision: PolicyDecision.ALLOW,
priority: 999,
modes: [ApprovalMode.YOLO],
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
// Simulate parsing failure
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 20,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
// Simulate parsing failure
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
expect(result.rule).toBeDefined();
expect(result.rule?.priority).toBe(20);
});
});
describe('safety checker integration', () => {
it('should call checker when rule allows and has safety_checker', async () => {
const rules: PolicyRule[] = [
+16 -2
View File
@@ -152,14 +152,28 @@ export class PolicyEngine {
const subCommands = splitCommands(command);
if (subCommands.length === 0) {
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
// In YOLO mode, we should proceed anyway even if we can't parse the command.
if (this.approvalMode === ApprovalMode.YOLO) {
return {
decision: PolicyDecision.ALLOW,
rule,
};
}
debugLogger.debug(
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`,
);
// Parsing logic failed, we can't trust it. Force ASK_USER (or DENY).
// We don't blame a specific rule here, unless the input rule was stricter.
// We return the rule that matched so the evaluation loop terminates.
return {
decision: this.applyNonInteractiveMode(PolicyDecision.ASK_USER),
rule: undefined,
rule,
};
}
+18
View File
@@ -177,6 +177,24 @@ describe('AskUserTool', () => {
expect(result).toBeNull();
});
it('should accept placeholder for choice type', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Which language?',
header: 'Language',
type: QuestionType.CHOICE,
options: [
{ label: 'TypeScript', description: 'Typed JavaScript' },
{ label: 'JavaScript', description: 'Dynamic language' },
],
placeholder: 'Type another language...',
},
],
});
expect(result).toBeNull();
});
it('should return error if option has empty label', () => {
const result = tool.validateToolParams({
questions: [
+1 -1
View File
@@ -90,7 +90,7 @@ export class AskUserTool extends BaseDeclarativeTool<
placeholder: {
type: 'string',
description:
"Only applies when type='text'. Hint text shown in the input field.",
"Hint text shown in the input field. For type='text', shown in the main input. For type='choice', shown in the 'Other' custom input.",
},
},
},
+11 -11
View File
@@ -101,33 +101,33 @@ describe('retryWithBackoff', () => {
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 10 maxAttempts if no options are provided', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
it('should default to 3 maxAttempts if no options are provided', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
const promise = retryWithBackoff(mockFn);
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 10'),
expect(promise).rejects.toThrow('Simulated error attempt 3'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(10);
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 10 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
it('should default to 3 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
const promise = retryWithBackoff(mockFn, { maxAttempts: undefined });
// Expect it to fail with the error from the 10th attempt.
// Expect it to fail with the error from the 3rd attempt.
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 10'),
expect(promise).rejects.toThrow('Simulated error attempt 3'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(10);
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should not retry if shouldRetry returns false', async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ export interface RetryOptions {
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 10,
maxAttempts: 3,
initialDelayMs: 5000,
maxDelayMs: 30000, // 30 seconds
shouldRetryOnError: isRetryableError,
-7
View File
@@ -1112,13 +1112,6 @@
},
"additionalProperties": false
},
"autoAccept": {
"title": "Auto Accept",
"description": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).",
"markdownDescription": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"approvalMode": {
"title": "Approval Mode",
"description": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet.",