From 9e09db1ddb39ef36583dfcb0b31d903e5f08f661 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Wed, 28 Jan 2026 09:02:41 -0800 Subject: [PATCH] feat(cli): enable activity logging for non-interactive mode and evals (#17703) --- evals/test-helper.ts | 33 +++++++++----- packages/cli/src/nonInteractiveCli.test.ts | 51 ++++++++++++++++++++++ packages/cli/src/nonInteractiveCli.ts | 8 ++++ packages/cli/src/utils/activityLogger.ts | 16 ++++--- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/evals/test-helper.ts b/evals/test-helper.ts index c5fd09091b..f4db62e876 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -34,6 +34,10 @@ export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES'; export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { const fn = async () => { const rig = new TestRig(); + const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); + const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`); + const logFile = path.join(logDir, `${sanitizedName}.log`); + let isSuccess = false; try { rig.setup(evalCase.name, evalCase.params); @@ -62,6 +66,9 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { const result = await rig.run({ args: evalCase.prompt, approvalMode: evalCase.approvalMode ?? 'yolo', + env: { + GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile, + }, }); const unauthorizedErrorPrefix = @@ -73,9 +80,16 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { } await evalCase.assert(rig, result); + isSuccess = true; } finally { - await logToFile( - evalCase.name, + if (isSuccess) { + await fs.promises.unlink(activityLogFile).catch((err) => { + if (err.code !== 'ENOENT') throw err; + }); + } + + await fs.promises.writeFile( + logFile, JSON.stringify(rig.readToolLogs(), null, 2), ); await rig.cleanup(); @@ -89,6 +103,13 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { } } +async function prepareLogDir(name: string) { + const logDir = 'evals/logs'; + await fs.promises.mkdir(logDir, { recursive: true }); + const sanitizedName = name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + return { logDir, sanitizedName }; +} + export interface EvalCase { name: string; params?: Record; @@ -97,11 +118,3 @@ export interface EvalCase { approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan'; assert: (rig: TestRig, result: string) => Promise; } - -async function logToFile(name: string, content: string) { - const logDir = 'evals/logs'; - await fs.promises.mkdir(logDir, { recursive: true }); - const sanitizedName = name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); - const logFile = `${logDir}/${sanitizedName}.log`; - await fs.promises.writeFile(logFile, content); -} diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 7ab94f5bf1..3217645211 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -38,6 +38,11 @@ import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); +const mockRegisterActivityLogger = vi.hoisted(() => vi.fn()); +vi.mock('./utils/activityLogger.js', () => ({ + registerActivityLogger: mockRegisterActivityLogger, +})); + const mockCoreEvents = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), @@ -259,6 +264,52 @@ describe('runNonInteractive', () => { // so we no longer expect shutdownTelemetry to be called directly here }); + it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl'); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger', + }); + + expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig); + vi.unstubAllEnvs(); + }); + + it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', ''); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger-off', + }); + + expect(mockRegisterActivityLogger).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + it('should handle a single tool call and respond', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 17d2537624..9535fbded2 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -70,6 +70,14 @@ export async function runNonInteractive({ coreEvents.emitConsoleLog(msg.type, msg.content); }, }); + + if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) { + const { registerActivityLogger } = await import( + './utils/activityLogger.js' + ); + registerActivityLogger(config); + } + const { stdout: workingStdout } = createWorkingStdio(); const textOutput = new TextOutput(workingStdout); diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index 486ed4858a..6bd4cc1318 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -323,13 +323,17 @@ export class ActivityLogger extends EventEmitter { } /** - * Registers the activity logger if debug mode and interactive session are enabled. + * Registers the activity logger. * Captures network and console logs to a session-specific JSONL file. * + * The log file location can be overridden via the GEMINI_CLI_ACTIVITY_LOG_FILE + * environment variable. If not set, defaults to logs/session-{sessionId}.jsonl + * in the project's temp directory. + * * @param config The CLI configuration */ export function registerActivityLogger(config: Config) { - if (config.isInteractive() && config.storage && config.getDebugMode()) { + if (config.storage) { const capture = ActivityLogger.getInstance(); capture.enable(); @@ -338,10 +342,10 @@ export function registerActivityLogger(config: Config) { fs.mkdirSync(logsDir, { recursive: true }); } - const logFile = path.join( - logsDir, - `session-${config.getSessionId()}.jsonl`, - ); + const logFile = + process.env['GEMINI_CLI_ACTIVITY_LOG_FILE'] || + path.join(logsDir, `session-${config.getSessionId()}.jsonl`); + const writeToLog = (type: 'console' | 'network', payload: unknown) => { try { const entry =