Skip to content

Commit 4b8d5e7

Browse files
authored
fix(cli): prevent ACP stdout pollution from SessionEnd hooks (#26125)
1 parent 7a3f7c3 commit 4b8d5e7

2 files changed

Lines changed: 132 additions & 2 deletions

File tree

packages/cli/src/gemini.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,6 @@ export async function main() {
385385
},
386386
});
387387
consolePatcher.patch();
388-
registerCleanup(consolePatcher.cleanup);
389388

390389
dns.setDefaultResultOrder(
391390
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
@@ -411,6 +410,7 @@ export async function main() {
411410
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
412411
projectHooks: settings.workspace.settings.hooks,
413412
});
413+
414414
adminControlsListner.setConfig(partialConfig);
415415

416416
// Refresh auth to fetch remote admin settings from CCPA and before entering
@@ -568,6 +568,12 @@ export async function main() {
568568
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
569569
});
570570

571+
// Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
572+
// are correctly redirected to stderr (especially for non-interactive JSON output).
573+
if (!config.getAcpMode()) {
574+
registerCleanup(consolePatcher.cleanup);
575+
}
576+
571577
// Launch cleanup expired sessions as a background task
572578
cleanupExpiredSessions(config, settings.merged).catch((e) => {
573579
debugLogger.error('Failed to cleanup expired sessions:', e);

packages/cli/src/gemini_cleanup.test.tsx

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ vi.mock('./config/settings.js', async (importOriginal) => {
6868
};
6969
});
7070

71+
vi.mock('./ui/utils/ConsolePatcher.js', () => ({
72+
ConsolePatcher: vi.fn().mockImplementation(() => ({
73+
patch: vi.fn(),
74+
cleanup: vi.fn(),
75+
})),
76+
}));
77+
7178
vi.mock('./config/config.js', () => ({
7279
loadCliConfig: vi.fn().mockResolvedValue({
7380
getSandbox: vi.fn(() => false),
@@ -150,6 +157,10 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
150157
};
151158
});
152159

160+
vi.mock('./acp/acpClient.js', () => ({
161+
runAcpClient: vi.fn().mockResolvedValue(undefined),
162+
}));
163+
153164
vi.mock('./zed-integration/zedIntegration.js', () => ({
154165
runZedIntegration: vi.fn().mockResolvedValue(undefined),
155166
}));
@@ -296,6 +307,120 @@ describe('gemini.tsx main function cleanup', () => {
296307
);
297308
});
298309

310+
it('should not register ConsolePatcher cleanup in ACP mode', async () => {
311+
const { registerCleanup } = await import('./utils/cleanup.js');
312+
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
313+
const { loadCliConfig, parseArguments } = await import(
314+
'./config/config.js'
315+
);
316+
const { loadSettings } = await import('./config/settings.js');
317+
318+
vi.mocked(parseArguments).mockResolvedValue({
319+
acp: true,
320+
startupMessages: [],
321+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
322+
} as any);
323+
324+
vi.mocked(loadSettings).mockReturnValue({
325+
merged: {
326+
tools: { allowed: [], exclude: [] },
327+
advanced: { dnsResolutionOrder: 'ipv4first' },
328+
security: { auth: { selectedType: 'google' } },
329+
ui: { theme: 'default' },
330+
},
331+
workspace: { settings: {} },
332+
errors: [],
333+
subscribe: vi.fn(),
334+
getSnapshot: vi.fn(),
335+
setValue: vi.fn(),
336+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
337+
} as any);
338+
339+
vi.mocked(loadCliConfig).mockResolvedValue(
340+
buildMockConfig({
341+
getAcpMode: () => true,
342+
}),
343+
);
344+
345+
let capturedCleanup: () => void;
346+
vi.mocked(ConsolePatcher).mockImplementation(() => {
347+
const instance = {
348+
patch: vi.fn(),
349+
cleanup: vi.fn(),
350+
};
351+
capturedCleanup = instance.cleanup;
352+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
353+
return instance as any;
354+
});
355+
356+
await main();
357+
358+
const registeredFunctions = vi
359+
.mocked(registerCleanup)
360+
.mock.calls.map((call) => call[0]);
361+
expect(registeredFunctions).not.toContain(capturedCleanup!);
362+
});
363+
364+
it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
365+
const { registerCleanup } = await import('./utils/cleanup.js');
366+
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
367+
const { loadCliConfig, parseArguments } = await import(
368+
'./config/config.js'
369+
);
370+
const { loadSettings } = await import('./config/settings.js');
371+
372+
vi.mocked(parseArguments).mockResolvedValue({
373+
acp: false,
374+
query: 'test',
375+
startupMessages: [],
376+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
377+
} as any);
378+
379+
vi.mocked(loadSettings).mockReturnValue({
380+
merged: {
381+
tools: { allowed: [], exclude: [] },
382+
advanced: { dnsResolutionOrder: 'ipv4first' },
383+
security: { auth: { selectedType: 'google' } },
384+
ui: { theme: 'default' },
385+
},
386+
workspace: { settings: {} },
387+
errors: [],
388+
subscribe: vi.fn(),
389+
getSnapshot: vi.fn(),
390+
setValue: vi.fn(),
391+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
392+
} as any);
393+
394+
vi.mocked(loadCliConfig).mockResolvedValue(
395+
buildMockConfig({
396+
getAcpMode: () => false,
397+
getQuestion: () => 'test',
398+
}),
399+
);
400+
401+
let capturedCleanup: () => void;
402+
vi.mocked(ConsolePatcher).mockImplementation(() => {
403+
const instance = {
404+
patch: vi.fn(),
405+
cleanup: vi.fn(),
406+
};
407+
capturedCleanup = instance.cleanup;
408+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
409+
return instance as any;
410+
});
411+
412+
try {
413+
await main();
414+
} catch {
415+
// Ignore errors from incomplete mocks in full main() execution
416+
}
417+
418+
const registeredFunctions = vi
419+
.mocked(registerCleanup)
420+
.mock.calls.map((call) => call[0]);
421+
expect(registeredFunctions).toContain(capturedCleanup!);
422+
});
423+
299424
function buildMockConfig(overrides: Partial<Config> = {}): Config {
300425
return {
301426
isInteractive: vi.fn(() => false),
@@ -319,7 +444,6 @@ describe('gemini.tsx main function cleanup', () => {
319444
getListExtensions: vi.fn(() => false),
320445
getListSessions: vi.fn(() => false),
321446
getDeleteSession: vi.fn(() => undefined),
322-
getToolRegistry: vi.fn(),
323447
getExtensions: vi.fn(() => []),
324448
getModel: vi.fn(() => 'gemini-pro'),
325449
getEmbeddingModel: vi.fn(() => 'embedding-001'),

0 commit comments

Comments
 (0)