diff --git a/eslint.config.js b/eslint.config.js index bd3585a92..1d0ed2af9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -145,6 +145,7 @@ export default tseslint.config( }, ], 'no-unsafe-finally': 'error', + 'no-console': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ // Enable TS version @@ -169,6 +170,7 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + 'no-console': 'off', // Allow console in tests '@typescript-eslint/no-unused-vars': [ 'error', { @@ -190,6 +192,7 @@ export default tseslint.config( }, }, rules: { + 'no-console': 'off', // Allow console in scripts '@typescript-eslint/no-unused-vars': [ 'error', { @@ -214,6 +217,28 @@ export default tseslint.config( 'no-undef': 'off', }, }, + // ==================== no-console allowlist ==================== + // The following files/packages are allowed to use console.* + + // VS Code IDE companion - out of scope for no-console rule + { + files: ['packages/vscode-ide-companion/**/*.ts', 'packages/vscode-ide-companion/**/*.tsx', 'packages/vscode-ide-companion/**/*.js'], + rules: { 'no-console': 'off' }, + }, + // WebUI package - UI component library with Storybook + { + files: ['packages/webui/**/*.ts', 'packages/webui/**/*.tsx', 'packages/webui/**/*.js'], + rules: { 'no-console': 'off' }, + }, + // Specific CLI files that intentionally wrap console usage + { + files: [ + 'packages/cli/src/acp-integration/acpAgent.ts', // console infrastructure for ACP mode + 'packages/cli/src/utils/stdioHelpers.ts', // wraps console.clear() + ], + rules: { 'no-console': 'off' }, + }, + // Specific esbuild configs not covered by scripts pattern { files: ['packages/vscode-ide-companion/esbuild.js'], languageOptions: { @@ -226,36 +251,7 @@ export default tseslint.config( rules: { 'no-restricted-syntax': 'off', '@typescript-eslint/no-require-imports': 'off', - }, - }, - // extra settings for scripts that we run directly with node - { - files: ['packages/vscode-ide-companion/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, - }, - // extra settings for core package scripts - { - files: ['packages/core/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', + 'no-console': 'off', }, }, // Settings for export-html assets @@ -290,6 +286,7 @@ export default tseslint.config( }, }, rules: { + 'no-console': 'off', // Allow console in integration tests '@typescript-eslint/no-unused-vars': [ 'error', { diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 2a3bd222c..904d61473 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -7,6 +7,7 @@ /* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ import { z } from 'zod'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import * as schema from './schema.js'; import { ACP_ERROR_CODES } from './errorCodes.js'; import { pickAuthMethodsForDetails } from './authMethods.js'; @@ -14,6 +15,7 @@ export * from './schema.js'; import type { WritableStream, ReadableStream } from 'node:stream/web'; +const debugLogger = createDebugLogger('ACP_PROTOCOL'); export class AgentSideConnection implements Client { #connection: Connection; @@ -222,8 +224,16 @@ class Connection { const trimmedLine = line.trim(); if (trimmedLine) { - const message = JSON.parse(trimmedLine); - this.#processMessage(message); + try { + const message = JSON.parse(trimmedLine); + this.#processMessage(message); + } catch (error) { + debugLogger.error('ACP parse error for inbound message.', { + code: ACP_ERROR_CODES.PARSE_ERROR, + line: trimmedLine, + error, + }); + } } } } @@ -260,13 +270,23 @@ class Connection { return { result: result ?? null }; } catch (error: unknown) { if (error instanceof RequestError) { + debugLogger.debug('ACP handler returned request error.', { + method, + code: error.code, + message: error.message, + details: error.data?.details, + }); return error.toResult(); } if (error instanceof z.ZodError) { - return RequestError.invalidParams( - JSON.stringify(error.format(), undefined, 2), - ).toResult(); + const formattedDetails = JSON.stringify(error.format(), undefined, 2); + debugLogger.debug('ACP handler validation error.', { + method, + code: ACP_ERROR_CODES.INVALID_PARAMS, + details: formattedDetails, + }); + return RequestError.invalidParams(formattedDetails).toResult(); } let errorName; @@ -291,6 +311,11 @@ class Connection { ).toResult(); } + debugLogger.error( + 'ACP handler failed with internal error.', + { method, errorName, details }, + error, + ); return RequestError.internalError(details).toResult(); } } @@ -301,7 +326,14 @@ class Connection { if ('result' in response) { pendingResponse.resolve(response.result); } else if ('error' in response) { - pendingResponse.reject(response.error); + const { error } = response; + debugLogger.warn('ACP response error received.', { + id: response.id, + code: error.code, + message: error.message, + data: error.data, + }); + pendingResponse.reject(error); } this.#pendingResponses.delete(response.id); } @@ -333,7 +365,7 @@ class Connection { }) .catch((error) => { // Continue processing writes on error - console.error('ACP write error:', error); + debugLogger.error('ACP write error:', error); }); return this.#writeQueue; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1e310356f..a7ae2cf4c 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -11,6 +11,7 @@ import { APPROVAL_MODES, AuthType, clearCachedCredentialFile, + createDebugLogger, QwenOAuth2Event, qwenOAuth2Events, MCPServerConfig, @@ -35,6 +36,8 @@ import { loadCliConfig } from '../config/config.js'; import { Session } from './session/Session.js'; import { formatAcpModelId } from '../utils/acpModelUtils.js'; +const debugLogger = createDebugLogger('ACP_AGENT'); + export async function runAcpAgent( config: Config, settings: LoadedSettings, @@ -291,7 +294,7 @@ class GeminiAgent { // Use true for the second argument to ensure only cached credentials are used await config.refreshAuth(selectedType, true); } catch (e) { - console.error(`Authentication failed: ${e}`); + debugLogger.error(`Authentication failed: ${e}`); throw acp.RequestError.authRequired( 'Authentication failed: ' + (e as Error).message, this.pickAuthMethodsForAuthRequired(selectedType, e), diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 0a8e7bba1..e562d8b86 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -187,9 +187,6 @@ describe('Session', () => { }); it('swallows errors and does not throw', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); getAvailableCommandsSpy.mockRejectedValueOnce( new Error('Command discovery failed'), ); @@ -198,8 +195,6 @@ describe('Session', () => { session.sendAvailableCommandsUpdate(), ).resolves.toBeUndefined(); expect(mockClient.sessionUpdate).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index cdbaaadae..d7a5e7395 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -22,6 +22,7 @@ import { AuthType, ApprovalMode, convertToFunctionResponse, + createDebugLogger, DiscoveredMCPTool, StreamEventType, ToolConfirmationOutcome, @@ -68,6 +69,8 @@ import { PlanEmitter } from './emitters/PlanEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import { SubAgentTracker } from './SubAgentTracker.js'; +const debugLogger = createDebugLogger('SESSION'); + /** * Session represents an active conversation session with the AI model. * It uses modular components for consistent event emission: @@ -319,7 +322,7 @@ export class Session implements SessionContext { await this.sendUpdate(update); } catch (error) { // Log error but don't fail session creation - console.error('Error sending available commands update:', error); + debugLogger.error('Error sending available commands update:', error); } } @@ -927,7 +930,7 @@ export class Session implements SessionContext { debug(msg: string): void { if (this.config.getDebugMode()) { - console.warn(msg); + debugLogger.warn(msg); } } } diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index 4643fe776..d020f2a06 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -18,6 +18,7 @@ import type { import { SubAgentEventType, ToolConfirmationOutcome, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import { z } from 'zod'; import type { SessionContext } from './types.js'; @@ -25,6 +26,8 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import type * as acp from '../acp.js'; +const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER'); + /** * Permission option kind type matching ACP schema. */ @@ -151,7 +154,7 @@ export class SubAgentTracker { invocation = tool.build(event.args); } catch (e) { // If building fails, continue with defaults - console.warn(`Failed to build subagent tool ${event.name}:`, e); + debugLogger.warn(`Failed to build subagent tool ${event.name}:`, e); } } @@ -268,7 +271,7 @@ export class SubAgentTracker { await event.respond(outcome); } catch (error) { // If permission request fails, cancel the tool call - console.error( + debugLogger.error( `Permission request failed for subagent tool ${event.name}:`, error, ); diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index 0f2321075..cfe4268e6 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -9,6 +9,7 @@ import type { ConfirmationRequest } from '../../ui/types.js'; import chalk from 'chalk'; import prompts from 'prompts'; import { t } from '../../i18n/index.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; /** * Requests consent from the user to perform an action, by reading a Y/n @@ -22,7 +23,7 @@ import { t } from '../../i18n/index.js'; export async function requestConsentNonInteractive( consentDescription: string, ): Promise { - console.info(consentDescription); + writeStdoutLine(consentDescription); const result = await promptForConsentNonInteractive( t('Do you want to continue? [Y/n]: '), ); diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts index 7bde3ee0a..6e54dd191 100644 --- a/packages/cli/src/commands/extensions/disable.test.ts +++ b/packages/cli/src/commands/extensions/disable.test.ts @@ -4,19 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { disableCommand, handleDisable } from './disable.js'; import yargs from 'yargs'; import { SettingScope } from '../../config/settings.js'; const mockDisableExtension = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -28,6 +23,12 @@ vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + describe('extensions disable command', () => { it('should fail if no name is provided', () => { const validationParser = yargs([]) @@ -59,20 +60,15 @@ describe('extensions disable command', () => { }); describe('handleDisable', () => { - let consoleLogSpy: MockInstance; - let consoleErrorSpy: MockInstance; - let processExitSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); vi.clearAllMocks(); }); it('should disable an extension with user scope', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + await handleDisable({ name: 'test-extension', scope: 'user', @@ -82,12 +78,18 @@ describe('handleDisable', () => { 'test-extension', SettingScope.User, ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully disabled for scope "user".', ); + + processExitSpy.mockRestore(); }); it('should disable an extension with workspace scope', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + await handleDisable({ name: 'test-extension', scope: 'workspace', @@ -97,12 +99,18 @@ describe('handleDisable', () => { 'test-extension', SettingScope.Workspace, ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully disabled for scope "workspace".', ); + + processExitSpy.mockRestore(); }); it('should default to user scope when no scope is provided', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + await handleDisable({ name: 'test-extension', }); @@ -111,9 +119,15 @@ describe('handleDisable', () => { 'test-extension', SettingScope.User, ); + + processExitSpy.mockRestore(); }); it('should handle errors and exit with code 1', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockDisableExtension.mockImplementationOnce(() => { throw new Error('Disable failed'); }); @@ -123,7 +137,9 @@ describe('handleDisable', () => { scope: 'user', }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Disable failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Disable failed'); expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); }); }); diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 92ebd6fa8..f13e3f550 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -7,6 +7,7 @@ import { type CommandModule } from 'yargs'; import { SettingScope } from '../../config/settings.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { getExtensionManager } from './utils.js'; import { t } from '../../i18n/index.js'; @@ -23,14 +24,14 @@ export async function handleDisable(args: DisableArgs) { } else { extensionManager.disableExtension(args.name, SettingScope.User); } - console.log( + writeStdoutLine( t('Extension "{{name}}" successfully disabled for scope "{{scope}}".', { name: args.name, scope: args.scope || SettingScope.User, }), ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts index 374918e0a..3f77b0f53 100644 --- a/packages/cli/src/commands/extensions/enable.test.ts +++ b/packages/cli/src/commands/extensions/enable.test.ts @@ -4,19 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { enableCommand, handleEnable } from './enable.js'; import yargs from 'yargs'; import { SettingScope } from '../../config/settings.js'; const mockEnableExtension = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -39,6 +33,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: vi.fn(), + clearScreen: vi.fn(), +})); + describe('extensions enable command', () => { it('should fail if no name is provided', () => { const validationParser = yargs([]) @@ -70,10 +70,7 @@ describe('extensions enable command', () => { }); describe('handleEnable', () => { - let consoleLogSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -87,7 +84,7 @@ describe('handleEnable', () => { 'test-extension', SettingScope.User, ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully enabled for scope "user".', ); }); @@ -102,7 +99,7 @@ describe('handleEnable', () => { 'test-extension', SettingScope.Workspace, ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully enabled for scope "workspace".', ); }); @@ -116,7 +113,7 @@ describe('handleEnable', () => { 'test-extension', SettingScope.User, ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully enabled in all scopes.', ); }); diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index b36e50ac9..b02e6ff75 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -7,6 +7,7 @@ import { type CommandModule } from 'yargs'; import { FatalConfigError, getErrorMessage } from '@qwen-code/qwen-code-core'; import { SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; import { getExtensionManager } from './utils.js'; import { t } from '../../i18n/index.js'; @@ -25,14 +26,14 @@ export async function handleEnable(args: EnableArgs) { extensionManager.enableExtension(args.name, SettingScope.User); } if (args.scope) { - console.log( + writeStdoutLine( t('Extension "{{name}}" successfully enabled for scope "{{scope}}".', { name: args.name, scope: args.scope, }), ); } else { - console.log( + writeStdoutLine( t('Extension "{{name}}" successfully enabled in all scopes.', { name: args.name, }), diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index f002d1a12..f49c2d48a 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -4,15 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; @@ -23,6 +15,8 @@ const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockRequestConsentOrFail = vi.hoisted(() => vi.fn()); const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); const mockLoadSettings = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', () => ({ ExtensionManager: vi.fn().mockImplementation(() => ({ @@ -50,6 +44,12 @@ vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]) @@ -63,16 +63,7 @@ describe('extensions install command', () => { }); describe('handleInstall', () => { - let consoleLogSpy: MockInstance; - let consoleErrorSpy: MockInstance; - let processSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log'); - consoleErrorSpy = vi.spyOn(console, 'error'); - processSpy = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); mockRefreshCache.mockResolvedValue(undefined); mockLoadSettings.mockReturnValue({ merged: {} }); mockIsWorkspaceTrusted.mockReturnValue(true); @@ -83,6 +74,10 @@ describe('handleInstall', () => { }); it('should install an extension from a http source', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'http', url: 'http://google.com', @@ -93,12 +88,18 @@ describe('handleInstall', () => { source: 'http://google.com', }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "http-extension" installed successfully and enabled.', ); + + processSpy.mockRestore(); }); it('should install an extension from a https source', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'https', url: 'https://google.com', @@ -109,12 +110,18 @@ describe('handleInstall', () => { source: 'https://google.com', }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "https-extension" installed successfully and enabled.', ); + + processSpy.mockRestore(); }); it('should install an extension from a git source', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'git', url: 'git@some-url', @@ -125,12 +132,18 @@ describe('handleInstall', () => { source: 'git@some-url', }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "git-extension" installed successfully and enabled.', ); + + processSpy.mockRestore(); }); it('throws an error from an unknown source', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockRejectedValue( new Error('Install source not found.'), ); @@ -138,11 +151,19 @@ describe('handleInstall', () => { source: 'test://google.com', }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Install source not found.', + ); expect(processSpy).toHaveBeenCalledWith(1); + + processSpy.mockRestore(); }); it('should install an extension from a sso source', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'sso', url: 'sso://google.com', @@ -153,12 +174,18 @@ describe('handleInstall', () => { source: 'sso://google.com', }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "sso-extension" installed successfully and enabled.', ); + + processSpy.mockRestore(); }); it('should install an extension from a local path', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'local', path: '/some/path', @@ -169,12 +196,18 @@ describe('handleInstall', () => { source: '/some/path', }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "local-extension" installed successfully and enabled.', ); + + processSpy.mockRestore(); }); it('should throw an error if install extension fails', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ type: 'git', url: 'git@some-url', @@ -185,7 +218,11 @@ describe('handleInstall', () => { await handleInstall({ source: 'git@some-url' }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Install extension failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Install extension failed', + ); expect(processSpy).toHaveBeenCalledWith(1); + + processSpy.mockRestore(); }); }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index f7fda09df..000184535 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -11,6 +11,7 @@ import { parseInstallSource, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { loadSettings } from '../../config/settings.js'; import { @@ -68,13 +69,13 @@ export async function handleInstall(args: InstallArgs) { }, requestConsent, ); - console.log( + writeStdoutLine( t('Extension "{{name}}" installed successfully and enabled.', { name: extension.name, }), ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts index babe4ce90..9aff17c8b 100644 --- a/packages/cli/src/commands/extensions/link.test.ts +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -4,18 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { linkCommand, handleLink } from './link.js'; import yargs from 'yargs'; const mockInstallExtension = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -32,6 +27,12 @@ vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + describe('extensions link command', () => { it('should fail if no path is provided', () => { const validationParser = yargs([]) @@ -50,20 +51,15 @@ describe('extensions link command', () => { }); describe('handleLink', () => { - let consoleLogSpy: MockInstance; - let consoleErrorSpy: MockInstance; - let processExitSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); vi.clearAllMocks(); }); it('should link an extension from a local path', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockInstallExtension.mockResolvedValueOnce({ name: 'linked-extension' }); await handleLink({ @@ -77,19 +73,27 @@ describe('handleLink', () => { }, expect.any(Function), ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "linked-extension" linked successfully and enabled.', ); + + processExitSpy.mockRestore(); }); it('should handle errors and exit with code 1', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockInstallExtension.mockRejectedValueOnce(new Error('Link failed')); await handleLink({ path: '/some/local/path', }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Link failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Link failed'); expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); }); }); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 545899cda..f03b51e46 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -7,6 +7,7 @@ import type { CommandModule } from 'yargs'; import { type ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { requestConsentNonInteractive, requestConsentOrFail, @@ -31,16 +32,16 @@ export async function handleLink(args: InstallArgs) { requestConsentOrFail.bind(null, requestConsentNonInteractive), ); if (!extension) { - console.log(t('Link extension failed to install.')); + writeStdoutLine(t('Link extension failed to install.')); return; } - console.log( + writeStdoutLine( t('Extension "{{name}}" linked successfully and enabled.', { name: extension.name, }), ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts index 8c7a24951..96b7cf8d9 100644 --- a/packages/cli/src/commands/extensions/list.test.ts +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -4,19 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { listCommand, handleList } from './list.js'; import yargs from 'yargs'; const mockGetLoadedExtensions = vi.hoisted(() => vi.fn()); const mockToOutputString = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -30,6 +25,12 @@ vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + describe('extensions list command', () => { it('should parse the list command', () => { const parser = yargs([]).command(listCommand).fail(false).locale('en'); @@ -38,28 +39,31 @@ describe('extensions list command', () => { }); describe('handleList', () => { - let consoleLogSpy: MockInstance; - let consoleErrorSpy: MockInstance; - let processExitSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); vi.clearAllMocks(); }); it('should display message when no extensions are installed', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockGetLoadedExtensions.mockReturnValueOnce([]); await handleList(); - expect(consoleLogSpy).toHaveBeenCalledWith('No extensions installed.'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'No extensions installed.', + ); + + processExitSpy.mockRestore(); }); it('should list installed extensions', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const mockExtensions = [ { name: 'extension-1', version: '1.0.0' }, { name: 'extension-2', version: '2.0.0' }, @@ -73,19 +77,27 @@ describe('handleList', () => { expect(mockGetLoadedExtensions).toHaveBeenCalled(); expect(mockToOutputString).toHaveBeenCalledTimes(2); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'extension-1 (1.0.0)\n\nextension-2 (2.0.0)', ); + + processExitSpy.mockRestore(); }); it('should handle errors and exit with code 1', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockGetLoadedExtensions.mockImplementationOnce(() => { throw new Error('List failed'); }); await handleList(); - expect(consoleErrorSpy).toHaveBeenCalledWith('List failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('List failed'); expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); }); }); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 6f5653be3..4444fba67 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { extensionToOutputString, getExtensionManager } from './utils.js'; import { t } from '../../i18n/index.js'; @@ -15,10 +16,10 @@ export async function handleList() { const extensions = extensionManager.getLoadedExtensions(); if (!extensions || extensions.length === 0) { - console.log(t('No extensions installed.')); + writeStdoutLine(t('No extensions installed.')); return; } - console.log( + writeStdoutLine( extensions .map((extension, _): string => extensionToOutputString(extension, extensionManager, process.cwd()), @@ -26,7 +27,7 @@ export async function handleList() { .join('\n\n'), ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts index 27f9c6dd0..f47648ab9 100644 --- a/packages/cli/src/commands/extensions/new.ts +++ b/packages/cli/src/commands/extensions/new.ts @@ -9,6 +9,7 @@ import { join, dirname, basename } from 'node:path'; import type { CommandModule } from 'yargs'; import { fileURLToPath } from 'node:url'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; interface NewArgs { path: string; @@ -52,7 +53,7 @@ async function handleNew(args: NewArgs) { try { if (args.template) { await copyDirectory(args.template, args.path); - console.log( + writeStdoutLine( `Successfully created new extension from template "${args.template}" at ${args.path}.`, ); } else { @@ -66,13 +67,13 @@ async function handleNew(args: NewArgs) { join(args.path, 'qwen-extension.json'), JSON.stringify(manifest, null, 2), ); - console.log(`Successfully created new extension at ${args.path}.`); + writeStdoutLine(`Successfully created new extension at ${args.path}.`); } - console.log( + writeStdoutLine( `You can install this using "qwen extensions link ${args.path}" to test it out.`, ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); throw error; } } diff --git a/packages/cli/src/commands/extensions/settings.test.ts b/packages/cli/src/commands/extensions/settings.test.ts index 042965eec..be043a2c2 100644 --- a/packages/cli/src/commands/extensions/settings.test.ts +++ b/packages/cli/src/commands/extensions/settings.test.ts @@ -4,14 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { settingsCommand } from './settings.js'; import yargs from 'yargs'; @@ -19,6 +12,13 @@ const mockGetLoadedExtensions = vi.hoisted(() => vi.fn()); const mockGetScopedEnvContents = vi.hoisted(() => vi.fn()); const mockUpdateSetting = vi.hoisted(() => vi.fn()); const mockPromptForSetting = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: vi.fn(), + clearScreen: vi.fn(), +})); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -89,10 +89,7 @@ describe('extensions settings command', () => { }); describe('settings set handler', () => { - let consoleLogSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -123,7 +120,7 @@ describe('settings set handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings set my-extension API_KEY'); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "my-extension" not found.', ); expect(mockUpdateSetting).not.toHaveBeenCalled(); @@ -173,10 +170,7 @@ describe('settings set handler', () => { }); describe('settings list handler', () => { - let consoleLogSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -207,7 +201,7 @@ describe('settings list handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings list my-extension'); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "my-extension" not found.', ); }); @@ -224,7 +218,7 @@ describe('settings list handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings list my-extension'); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "my-extension" has no settings to configure.', ); }); @@ -257,10 +251,16 @@ describe('settings list handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings list my-extension'); - expect(consoleLogSpy).toHaveBeenCalledWith('Settings for "my-extension":'); - expect(consoleLogSpy).toHaveBeenCalledWith('\n- API Key (API_KEY)'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Description: Your API key'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Value: my-api-key (user)'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Settings for "my-extension":', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith('\n- API Key (API_KEY)'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + ' Description: Your API key', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + ' Value: my-api-key (user)', + ); }); it('should show workspace scope for workspace-scoped settings', async () => { @@ -286,7 +286,7 @@ describe('settings list handler', () => { await parser.parseAsync('settings list my-extension'); // Workspace should override user, and show (workspace) scope - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( ' Value: workspace-value (workspace)', ); }); @@ -313,7 +313,7 @@ describe('settings list handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings list my-extension'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Value: [not set]'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith(' Value: [not set]'); }); it('should show [value stored in keychain] for sensitive settings', async () => { @@ -338,7 +338,7 @@ describe('settings list handler', () => { const parser = yargs([]).command(settingsCommand); await parser.parseAsync('settings list my-extension'); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( ' Value: [value stored in keychain] (user)', ); }); diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts index 49baf2cc4..65c54b570 100644 --- a/packages/cli/src/commands/extensions/settings.ts +++ b/packages/cli/src/commands/extensions/settings.ts @@ -13,6 +13,7 @@ import { updateSetting, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; // --- SET COMMAND --- interface SetArgs { @@ -50,7 +51,7 @@ const setCommand: CommandModule = { if (!extensions || extensions.length === 0) return; const extension = extensions.find((e) => e.name === name); if (!extension) { - console.log(t('Extension "{{name}}" not found.', { name })); + writeStdoutLine(t('Extension "{{name}}" not found.', { name })); return; } await updateSetting( @@ -85,11 +86,11 @@ const listCommand: CommandModule = { if (!extensions || extensions.length === 0) return; const extension = extensions.find((e) => e.name === name); if (!extension) { - console.log(t('Extension "{{name}}" not found.', { name })); + writeStdoutLine(t('Extension "{{name}}" not found.', { name })); return; } if (!extension || !extension.settings || extension.settings.length === 0) { - console.log( + writeStdoutLine( t('Extension "{{name}}" has no settings to configure.', { name }), ); return; @@ -107,7 +108,7 @@ const listCommand: CommandModule = { ); const mergedSettings = { ...userSettings, ...workspaceSettings }; - console.log(t('Settings for "{{name}}":', { name })); + writeStdoutLine(t('Settings for "{{name}}":', { name })); for (const setting of extension.settings) { const value = mergedSettings[setting.envVar]; let displayValue: string; @@ -126,10 +127,10 @@ const listCommand: CommandModule = { } else { displayValue = value; } - console.log(` + writeStdoutLine(` - ${setting.name} (${setting.envVar})`); - console.log(` ${t('Description:')} ${setting.description}`); - console.log(` ${t('Value:')} ${displayValue}${scopeInfo}`); + writeStdoutLine(` ${t('Description:')} ${setting.description}`); + writeStdoutLine(` ${t('Value:')} ${displayValue}${scopeInfo}`); } }, }; diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 980472a68..551b67771 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { ExtensionManager } from '@qwen-code/qwen-code-core'; import { requestConsentNonInteractive, @@ -34,11 +35,11 @@ export async function handleUninstall(args: UninstallArgs) { }); await extensionManager.refreshCache(); await extensionManager.uninstallExtension(args.name, false); - console.log( + writeStdoutLine( t('Extension "{{name}}" successfully uninstalled.', { name: args.name }), ); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts index 7dfeec1d6..b78ea6608 100644 --- a/packages/cli/src/commands/extensions/update.test.ts +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -4,14 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { updateCommand, handleUpdate } from './update.js'; import yargs from 'yargs'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; @@ -21,6 +14,8 @@ const mockUpdateExtension = vi.hoisted(() => vi.fn()); const mockCheckForAllExtensionUpdates = vi.hoisted(() => vi.fn()); const mockUpdateAllUpdatableExtensions = vi.hoisted(() => vi.fn()); const mockCheckForExtensionUpdate = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', () => ({ getExtensionManager: vi.fn().mockResolvedValue({ @@ -47,6 +42,12 @@ vi.mock('../../ui/state/extensions.js', () => ({ }, })); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + describe('extensions update command', () => { it('should fail if neither name nor --all is provided', () => { const validationParser = yargs([]) @@ -80,12 +81,7 @@ describe('extensions update command', () => { }); describe('handleUpdate', () => { - let consoleLogSpy: MockInstance; - let consoleErrorSpy: MockInstance; - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -95,7 +91,7 @@ describe('handleUpdate', () => { await handleUpdate({ name: 'non-existent-extension' }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "non-existent-extension" not found.', ); }); @@ -107,7 +103,7 @@ describe('handleUpdate', () => { await handleUpdate({ name: 'test-extension' }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Unable to install extension "test-extension" due to missing install metadata', ); }); @@ -124,7 +120,7 @@ describe('handleUpdate', () => { await handleUpdate({ name: 'test-extension' }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" is already up to date.', ); }); @@ -151,7 +147,7 @@ describe('handleUpdate', () => { ExtensionUpdateState.UPDATE_AVAILABLE, expect.any(Function), ); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" successfully updated: 1.0.0 → 2.0.0.', ); }); @@ -173,7 +169,7 @@ describe('handleUpdate', () => { await handleUpdate({ name: 'test-extension' }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "test-extension" is already up to date.', ); }); @@ -190,7 +186,7 @@ describe('handleUpdate', () => { await handleUpdate({ name: 'test-extension' }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Update check failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Update check failed'); }); }); @@ -201,7 +197,9 @@ describe('handleUpdate', () => { await handleUpdate({ all: true }); - expect(consoleLogSpy).toHaveBeenCalledWith('No extensions to update.'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'No extensions to update.', + ); }); it('should update all extensions with updates available', async () => { @@ -221,7 +219,7 @@ describe('handleUpdate', () => { await handleUpdate({ all: true }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.\n' + 'Extension "extension-2" successfully updated: 1.0.0 → 1.5.0.', ); @@ -244,7 +242,7 @@ describe('handleUpdate', () => { await handleUpdate({ all: true }); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.', ); }); @@ -256,7 +254,7 @@ describe('handleUpdate', () => { await handleUpdate({ all: true }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Update all failed'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Update all failed'); }); }); }); diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 9325a25f1..d47816b1c 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { checkForExtensionUpdate, @@ -39,11 +40,13 @@ export async function handleUpdate(args: UpdateArgs) { (extension) => extension.name === args.name, ); if (!extension) { - console.log(t('Extension "{{name}}" not found.', { name: args.name })); + writeStdoutLine( + t('Extension "{{name}}" not found.', { name: args.name }), + ); return; } if (!extension.installMetadata) { - console.log( + writeStdoutLine( t( 'Unable to install extension "{{name}}" due to missing install metadata', { name: args.name }, @@ -56,7 +59,7 @@ export async function handleUpdate(args: UpdateArgs) { extensionManager, ); if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { - console.log( + writeStdoutLine( t('Extension "{{name}}" is already up to date.', { name: args.name }), ); return; @@ -71,7 +74,7 @@ export async function handleUpdate(args: UpdateArgs) { updatedExtensionInfo.originalVersion !== updatedExtensionInfo.updatedVersion ) { - console.log( + writeStdoutLine( t( 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.', { @@ -82,12 +85,12 @@ export async function handleUpdate(args: UpdateArgs) { ), ); } else { - console.log( + writeStdoutLine( t('Extension "{{name}}" is already up to date.', { name: args.name }), ); } } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); } } if (args.all) { @@ -109,12 +112,12 @@ export async function handleUpdate(args: UpdateArgs) { (info) => info.originalVersion !== info.updatedVersion, ); if (updateInfos.length === 0) { - console.log(t('No extensions to update.')); + writeStdoutLine(t('No extensions to update.')); return; } - console.log(updateInfos.map((info) => updateOutput(info)).join('\n')); + writeStdoutLine(updateInfos.map((info) => updateOutput(info)).join('\n')); } catch (error) { - console.error(getErrorMessage(error)); + writeStderrLine(getErrorMessage(error)); } } } diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index 94c85f656..19a069975 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -9,6 +9,15 @@ import { addCommand } from './add.js'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -41,15 +50,13 @@ const mockedLoadSettings = loadSettings as vi.Mock; describe('mcp add command', () => { let parser: yargs.Argv; let mockSetValue: vi.Mock; - let mockConsoleError: vi.Mock; beforeEach(() => { vi.resetAllMocks(); const yargsInstance = yargs([]).command(addCommand); parser = yargsInstance; mockSetValue = vi.fn(); - mockConsoleError = vi.fn(); - vi.spyOn(console, 'error').mockImplementation(mockConsoleError); + mockWriteStderrLine.mockClear(); mockedLoadSettings.mockReturnValue({ forScope: () => ({ settings: {} }), setValue: mockSetValue, @@ -218,7 +225,7 @@ describe('mcp add command', () => { parser.parseAsync(`add ${serverName} ${command}`), ).rejects.toThrow('process.exit called'); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( 'Error: Please use --scope user to edit settings in the home directory.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); @@ -236,7 +243,7 @@ describe('mcp add command', () => { parser.parseAsync(`add --scope project ${serverName} ${command}`), ).rejects.toThrow('process.exit called'); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( 'Error: Please use --scope user to edit settings in the home directory.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); @@ -250,7 +257,7 @@ describe('mcp add command', () => { 'mcpServers', expect.any(Object), ); - expect(mockConsoleError).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 852f8bdae..bbaf79961 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -7,6 +7,7 @@ // File for 'gemini mcp add' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; async function addMcpServer( @@ -41,7 +42,7 @@ async function addMcpServer( const inHome = settings.workspace.path === settings.user.path; if (scope === 'project' && inHome) { - console.error( + writeStderrLine( 'Error: Please use --scope user to edit settings in the home directory.', ); process.exit(1); @@ -116,7 +117,7 @@ async function addMcpServer( const isExistingServer = !!mcpServers[name]; if (isExistingServer) { - console.log( + writeStdoutLine( `MCP server "${name}" is already configured within ${scope} settings.`, ); } @@ -126,9 +127,9 @@ async function addMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); if (isExistingServer) { - console.log(`MCP server "${name}" updated in ${scope} settings.`); + writeStdoutLine(`MCP server "${name}" updated in ${scope} settings.`); } else { - console.log( + writeStdoutLine( `MCP server "${name}" added to ${scope} settings. (${transport})`, ); } diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 438dcad59..ec7d184dc 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -4,13 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings } from '../../config/settings.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { createTransport, ExtensionManager } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + vi.mock('../../config/settings.js', () => ({ loadSettings: vi.fn(), })); @@ -46,7 +55,6 @@ interface MockTransport { } describe('mcp list command', () => { - let consoleSpy: vi.SpyInstance; let mockClient: MockClient; let mockTransport: MockTransport; let mockExtensionManager: { @@ -56,8 +64,7 @@ describe('mcp list command', () => { beforeEach(() => { vi.resetAllMocks(); - - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockWriteStdoutLine.mockClear(); mockTransport = { close: vi.fn() }; mockClient = { @@ -77,16 +84,14 @@ describe('mcp list command', () => { mockedIsWorkspaceTrusted.mockReturnValue(true); }); - afterEach(() => { - consoleSpy.mockRestore(); - }); - it('should display message when no servers configured', async () => { mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); await listMcpServers(); - expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'No MCP servers configured.', + ); }); it('should display different server types with connected status', async () => { @@ -105,18 +110,20 @@ describe('mcp list command', () => { await listMcpServers(); - expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n'); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Configured MCP servers:\n', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'stdio-server: /path/to/server arg1 (stdio) - Connected', ), ); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'sse-server: https://example.com/sse (sse) - Connected', ), ); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'http-server: https://example.com/http (http) - Connected', ), @@ -136,7 +143,7 @@ describe('mcp list command', () => { await listMcpServers(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), @@ -165,12 +172,12 @@ describe('mcp list command', () => { await listMcpServers(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'config-server: /config/server (stdio) - Connected', ), ); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( 'extension-server: /ext/server (stdio) - Connected', ), diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 8836e55c0..b754b2754 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -7,6 +7,7 @@ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; import { loadSettings } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import { MCPServerStatus, @@ -96,11 +97,11 @@ export async function listMcpServers(): Promise { const serverNames = Object.keys(mcpServers); if (serverNames.length === 0) { - console.log('No MCP servers configured.'); + writeStdoutLine('No MCP servers configured.'); return; } - console.log('Configured MCP servers:\n'); + writeStdoutLine('Configured MCP servers:\n'); for (const serverName of serverNames) { const server = mcpServers[serverName]; @@ -134,7 +135,7 @@ export async function listMcpServers(): Promise { serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; } - console.log(`${statusIndicator} ${serverInfo} - ${statusText}`); + writeStdoutLine(`${statusIndicator} ${serverInfo} - ${statusText}`); } } diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts index 0f3847e40..4bae8a6ed 100644 --- a/packages/cli/src/commands/mcp/remove.test.ts +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -9,6 +9,15 @@ import yargs from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { removeCommand } from './remove.js'; +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -49,6 +58,7 @@ describe('mcp remove command', () => { forScope: () => ({ settings: mockSettings }), setValue: mockSetValue, }); + mockWriteStdoutLine.mockClear(); }); it('should remove a server from project settings', async () => { @@ -62,11 +72,10 @@ describe('mcp remove command', () => { }); it('should show a message if server not found', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await parser.parseAsync('remove non-existent-server'); expect(mockSetValue).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Server "non-existent-server" not found in project settings.', ); }); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index bcaa5ad4b..87d73cf6c 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -7,6 +7,7 @@ // File for 'gemini mcp remove' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; async function removeMcpServer( name: string, @@ -23,7 +24,7 @@ async function removeMcpServer( const mcpServers = existingSettings.mcpServers || {}; if (!mcpServers[name]) { - console.log(`Server "${name}" not found in ${scope} settings.`); + writeStdoutLine(`Server "${name}" not found in ${scope} settings.`); return; } @@ -31,7 +32,7 @@ async function removeMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); - console.log(`Server "${name}" removed from ${scope} settings.`); + writeStdoutLine(`Server "${name}" removed from ${scope} settings.`); } export const removeCommand: CommandModule = { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 16ec45c65..7159cd284 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -20,6 +20,15 @@ import type { Settings } from './settings.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: mockWriteStdoutLine, + clearScreen: vi.fn(), +})); + const createNativeLspServiceInstance = () => ({ discoverAndPrepare: vi.fn(), start: vi.fn(), @@ -158,23 +167,19 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should throw an error when using short flags -p and -i together', async () => { @@ -190,23 +195,19 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should allow --prompt without --prompt-interactive', async () => { @@ -389,23 +390,19 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should throw an error when using short flags -y and --approval-mode together', async () => { @@ -414,23 +411,19 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should throw an error when include-partial-messages is used without stream-json output', async () => { @@ -439,23 +432,19 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( '--include-partial-messages requires --output-format stream-json', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should parse stream-json formats and include-partial-messages flag', async () => { @@ -496,21 +485,17 @@ describe('parseArguments', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should support comma-separated values for --allowed-tools', async () => { @@ -900,21 +885,17 @@ describe('loadCliConfig telemetry', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); }); @@ -2138,17 +2119,14 @@ describe('Output format', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); }); @@ -2172,23 +2150,19 @@ describe('parseArguments with positional prompt', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); await expect(parseArguments({} as Settings)).rejects.toThrow( 'process.exit called', ); - expect(mockConsoleError).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both a positional prompt and the --prompt (-p) flag together', ), ); mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); it('should correctly parse a positional prompt to query field', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f241ec1ca..88117ed6e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -29,6 +29,7 @@ import { ShellTool, WriteFileTool, NativeLspClient, + createDebugLogger, NativeLspService, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; @@ -51,16 +52,9 @@ import { mcpCommand } from '../commands/mcp.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { buildWebSearchConfig } from './webSearch.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; -// Simple console logger for now - replace with actual logger if available -const logger = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - debug: (...args: any[]) => console.debug('[DEBUG]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warn: (...args: any[]) => console.warn('[WARN]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: (...args: any[]) => console.error('[ERROR]', ...args), -}; +const debugLogger = createDebugLogger('CONFIG'); const VALID_APPROVAL_MODE_VALUES = [ 'plan', @@ -511,7 +505,7 @@ export async function parseArguments(settings: Settings): Promise { ) // Ensure validation flows through .fail() for clean UX .fail((msg: string, err: Error | undefined, yargs: Argv) => { - console.error(msg || err?.message || 'Unknown error'); + writeStderrLine(msg || err?.message || 'Unknown error'); yargs.showHelp(); process.exit(1); }) @@ -603,7 +597,7 @@ export async function parseArguments(settings: Settings): Promise { // Handle deprecated --experimental-acp flag if (result['experimentalAcp']) { - console.warn( + writeStderrLine( '\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m', ); // Map experimental-acp to acp if acp is not explicitly set @@ -626,7 +620,6 @@ export async function parseArguments(settings: Settings): Promise { export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, includeDirectoriesToReadGemini: readonly string[] = [], - debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, @@ -641,17 +634,10 @@ export async function loadHierarchicalGeminiMemory( // function to signal that it should skip the workspace search. const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory; - if (debugMode) { - logger.debug( - `CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`, - ); - } - // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, includeDirectoriesToReadGemini, - debugMode, fileService, extensionContextFilePaths, folderTrust, @@ -698,11 +684,7 @@ export async function loadCliConfig( 'output-language.md', ); if (fs.existsSync(outputLanguageFilePath)) { - if (debugMode) { - logger.debug( - `Found output-language.md, adding to context files: ${outputLanguageFilePath}`, - ); - } + // output-language.md found - will be added to context files } else { outputLanguageFilePath = undefined; } @@ -752,7 +734,7 @@ export async function loadCliConfig( approvalMode !== ApprovalMode.DEFAULT && approvalMode !== ApprovalMode.PLAN ) { - logger.warn( + writeStderrLine( `Approval mode overridden to "default" because the current folder is not trusted.`, ); approvalMode = ApprovalMode.DEFAULT; @@ -919,7 +901,7 @@ export async function loadCliConfig( sessionData = await sessionService.loadSession(argv.resume); if (!sessionData) { const message = `No saved session found with ID ${argv.resume}. Run \`qwen --resume\` without an ID to choose from existing sessions.`; - console.log(message); + writeStderrLine(message); process.exit(1); } } @@ -1042,7 +1024,7 @@ export async function loadCliConfig( lspClient = new NativeLspClient(lspService); config.setLspClient(lspClient); } catch (err) { - logger.warn('Failed to initialize native LSP service:', err); + debugLogger.warn('Failed to initialize native LSP service:', err); } } diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index dc53448d4..8737866ea 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -45,7 +45,6 @@ export enum Command { PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage', // App level bindings - SHOW_ERROR_DETAILS = 'showErrorDetails', TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions', TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', QUIT = 'quit', @@ -156,7 +155,6 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], // App level bindings - [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], [Command.QUIT]: [{ key: 'c', ctrl: true }], diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 089bb3ef6..f69178570 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -30,6 +30,7 @@ import { import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; @@ -959,7 +960,7 @@ export function loadSettings( 'utf-8', ); } catch (e) { - console.error( + writeStderrLine( `Error migrating settings file on disk: ${getErrorMessage( e, )}`, @@ -981,7 +982,7 @@ export function loadSettings( 'utf-8', ); } catch (e) { - console.error( + writeStderrLine( `Error adding version to settings file: ${getErrorMessage(e)}`, ); } @@ -1004,7 +1005,7 @@ export function loadSettings( 'utf-8', ); } catch (e) { - console.error( + writeStderrLine( `Error migrating settings file to V3: ${getErrorMessage(e)}`, ); } @@ -1146,9 +1147,6 @@ export function migrateDeprecatedSettings( legacySkills !== undefined && settings.experimental?.skills === undefined ) { - console.log( - `Migrating deprecated tools.experimental.skills setting from ${scope} settings...`, - ); loadedSettings.setValue(scope, 'experimental.skills', legacySkills); } }; @@ -1178,7 +1176,8 @@ export function saveSettings(settingsFile: SettingsFile): void { settingsToSave as Record, ); } catch (error) { - console.error('Error saving user settings file:', error); + writeStderrLine('Error saving user settings file.'); + writeStderrLine(error instanceof Error ? error.message : String(error)); throw error; } } diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 60a897f19..355146025 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -15,6 +15,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export const SETTINGS_DIRECTORY_NAME = '.qwen'; @@ -184,7 +185,8 @@ export function saveTrustedFolders( { encoding: 'utf-8', mode: 0o600 }, ); } catch (error) { - console.error('Error saving trusted folders file:', error); + writeStderrLine('Error saving trusted folders file.'); + writeStderrLine(error instanceof Error ? error.message : String(error)); } } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c75dc78a2..f3a788e72 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -24,6 +24,8 @@ import { appEvents, AppEvent } from './utils/events.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { OutputFormat } from '@qwen-code/qwen-code-core'; +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { constructor(readonly code?: string | number | null | undefined) { @@ -79,6 +81,12 @@ vi.mock('./utils/sandbox.js', () => ({ start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves })); +vi.mock('./utils/stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: vi.fn(), + clearScreen: vi.fn(), +})); + vi.mock('./utils/relaunch.js', () => ({ relaunchAppInChildProcess: vi.fn(), })); @@ -499,34 +507,28 @@ describe('gemini.tsx main function kitty protocol', () => { }); describe('validateDnsResolutionOrder', () => { - let consoleWarnSpy: ReturnType; - beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); + mockWriteStderrLine.mockClear(); }); it('should return "ipv4first" when the input is "ipv4first"', () => { expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first'); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('should return "verbatim" when the input is "verbatim"', () => { expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim'); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('should return the default "ipv4first" when the input is undefined', () => { expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first'); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('should return the default "ipv4first" and log a warning for an invalid string', () => { expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); - expect(consoleWarnSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', ); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5f839694a..459082872 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,8 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@qwen-code/qwen-code-core'; -import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core'; +import { + InputFormat, + isDebugLoggingDegraded, + logUserPrompt, + Storage, + type Config, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; import { render } from 'ink'; import dns from 'node:dns'; import os from 'node:os'; @@ -31,7 +37,6 @@ import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { themeManager } from './ui/themes/theme-manager.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { @@ -50,11 +55,14 @@ import { start_sandbox } from './utils/sandbox.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; +import { writeStderrLine } from './utils/stdioHelpers.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; +const debugLogger = createDebugLogger('STARTUP'); + export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { @@ -66,7 +74,7 @@ export function validateDnsResolutionOrder( return order; } // We don't want to throw here, just warn and use the default. - console.warn( + writeStderrLine( `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, ); return defaultValue; @@ -82,7 +90,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { // Set target to 50% of total memory const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); if (isDebugMode) { - console.debug( + writeStderrLine( `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, ); } @@ -93,7 +101,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { if (isDebugMode) { - console.debug( + writeStderrLine( `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, ); } @@ -191,9 +199,7 @@ export async function startInteractiveUI( }) .catch((err) => { // Silently ignore update check errors. - if (config.getDebugMode()) { - console.error('Update check failed:', err); - } + debugLogger.warn(`Update check failed: ${err}`); }); } @@ -209,7 +215,7 @@ export async function main() { // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { - console.error( + writeStderrLine( 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.', ); process.exit(1); @@ -228,7 +234,9 @@ export async function main() { if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { // If the theme is not found during initial load, log a warning and continue. // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. - console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`); + writeStderrLine( + `Warning: Theme "${settings.merged.ui?.theme}" not found.`, + ); } } @@ -267,7 +275,7 @@ export async function main() { await partialConfig.refreshAuth(authType); } } catch (err) { - console.error('Error authenticating:', err); + writeStderrLine(`Error authenticating: ${err}`); process.exit(1); } } @@ -355,15 +363,6 @@ export async function main() { // process.exit(0); // } - // Setup unified ConsolePatcher based on interactive mode - const isInteractive = config.isInteractive(); - const consolePatcher = new ConsolePatcher({ - stderr: isInteractive, - debugMode: isDebugMode, - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { @@ -429,6 +428,19 @@ export async function main() { return; } + // Print debug mode notice to stderr for non-interactive mode + if (config.getDebugMode()) { + writeStderrLine('Debug mode enabled'); + writeStderrLine( + `Logging to: ${Storage.getDebugLogPath(config.getSessionId())}`, + ); + if (isDebugLoggingDegraded()) { + writeStderrLine( + 'Warning: Debug logging is degraded (write failures occurred)', + ); + } + } + // For non-stream-json mode, initialize config here if (inputFormat !== InputFormat.STREAM_JSON) { await config.initialize(); @@ -464,7 +476,7 @@ export async function main() { } if (!input) { - console.error( + writeStderrLine( `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, ); process.exit(1); @@ -479,9 +491,7 @@ export async function main() { prompt_length: input.length, }); - if (config.getDebugMode()) { - console.log('Session ID: %s', config.getSessionId()); - } + debugLogger.debug(`Session ID: ${config.getSessionId()}`); await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id); // Call cleanup before process.exit, which causes cleanup to not run diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 64384029d..b22c8c9b2 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { homedir } from 'node:os'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; import { type SupportedLanguage, SUPPORTED_LANGUAGES, @@ -26,7 +27,6 @@ type TranslationValue = string | string[]; type TranslationDict = Record; const translationCache: Record = {}; const loadingPromises: Record> = {}; - // Path helpers const getBuiltinLocalesDir = (): string => { const __filename = fileURLToPath(import.meta.url); @@ -145,16 +145,13 @@ async function loadTranslationsAsync( } catch (error) { // Log warning but continue to next directory if (isUser) { - console.warn( - `Failed to load translations from user directory for ${lang}:`, - error, + writeStderrLine( + `Failed to load translations from user directory for ${lang}: ${error instanceof Error ? error.message : String(error)}`, ); } else { - console.warn(`Failed to load JS translations for ${lang}:`, error); - if (error instanceof Error) { - console.warn(`Error details: ${error.message}`); - console.warn(`Stack: ${error.stack}`); - } + writeStderrLine( + `Failed to load JS translations for ${lang}: ${error instanceof Error ? error.message : String(error)}`, + ); } // Continue to next directory continue; @@ -213,7 +210,7 @@ export function setLanguage(lang: SupportedLanguage | 'auto'): void { const userJsPath = getLocalePath(resolvedLang, true); const builtinJsPath = getLocalePath(resolvedLang, false); if (fs.existsSync(userJsPath) || fs.existsSync(builtinJsPath)) { - console.warn( + writeStderrLine( `Language file for ${resolvedLang} requires async loading. ` + `Use setLanguageAsync() instead, or call initializeI18n() first.`, ); diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts index b775b0a5e..ac42f3725 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts @@ -457,9 +457,6 @@ describe('ControlDispatcher', () => { it('should handle response for non-existent request in debug mode', () => { const context = createMockContext(true); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const dispatcherWithDebug = new ControlDispatcher(context); const response: CLIControlResponse = { @@ -471,15 +468,10 @@ describe('ControlDispatcher', () => { }, }; - dispatcherWithDebug.handleControlResponse(response); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - '[ControlDispatcher] No pending outgoing request for: non-existent', - ), - ); - - consoleSpy.mockRestore(); + // Should not throw in debug mode + expect(() => + dispatcherWithDebug.handleControlResponse(response), + ).not.toThrow(); }); }); @@ -599,11 +591,8 @@ describe('ControlDispatcher', () => { expect(() => dispatcher.handleCancel('non-existent')).not.toThrow(); }); - it('should log cancellation in debug mode', () => { + it('should cancel request in debug mode without throwing', () => { const context = createMockContext(true); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const dispatcherWithDebug = new ControlDispatcher(context); const requestId = 'cancel-req-debug'; @@ -626,15 +615,7 @@ describe('ControlDispatcher', () => { timeoutId, ); - dispatcherWithDebug.handleCancel(requestId); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - '[ControlDispatcher] Cancelled incoming request: cancel-req-debug', - ), - ); - - consoleSpy.mockRestore(); + expect(() => dispatcherWithDebug.handleCancel(requestId)).not.toThrow(); }); }); @@ -720,11 +701,8 @@ describe('ControlDispatcher', () => { expect(secondRejectCount).toBe(firstRejectCount); }); - it('should log input closure in debug mode', () => { + it('should mark input closed in debug mode without throwing', () => { const context = createMockContext(true); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const dispatcherWithDebug = new ControlDispatcher(context); const requestId = 'reject-req-debug'; @@ -750,15 +728,7 @@ describe('ControlDispatcher', () => { timeoutId, ); - dispatcherWithDebug.markInputClosed(); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - '[ControlDispatcher] Input closed, rejecting 1 pending outgoing requests', - ), - ); - - consoleSpy.mockRestore(); + expect(() => dispatcherWithDebug.markInputClosed()).not.toThrow(); }); }); @@ -848,21 +818,12 @@ describe('ControlDispatcher', () => { expect(mockSystemController.cleanup).toHaveBeenCalled(); }); - it('should log shutdown in debug mode', () => { + it('should shutdown in debug mode without throwing', () => { const context = createMockContext(true); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const dispatcherWithDebug = new ControlDispatcher(context); - dispatcherWithDebug.shutdown(); - - expect(consoleSpy).toHaveBeenCalledWith( - '[ControlDispatcher] Shutting down', - ); - - consoleSpy.mockRestore(); + expect(() => dispatcherWithDebug.shutdown()).not.toThrow(); }); }); diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index 4b3e9a5e7..f2fb267c7 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -38,6 +38,9 @@ import type { ControlResponse, ControlRequestPayload, } from '../types.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('CONTROL_DISPATCHER'); /** * Tracks an incoming request from SDK awaiting CLI response @@ -135,11 +138,9 @@ export class ControlDispatcher implements IPendingRequestRegistry { const pending = this.pendingOutgoingRequests.get(requestId); if (!pending) { // No pending request found - may have timed out or been cancelled - if (this.context.debugMode) { - console.error( - `[ControlDispatcher] No pending outgoing request for: ${requestId}`, - ); - } + debugLogger.debug( + `[ControlDispatcher] No pending outgoing request for: ${requestId}`, + ); return; } @@ -181,11 +182,9 @@ export class ControlDispatcher implements IPendingRequestRegistry { this.deregisterIncomingRequest(requestId); this.sendErrorResponse(requestId, 'Request cancelled'); - if (this.context.debugMode) { - console.error( - `[ControlDispatcher] Cancelled incoming request: ${requestId}`, - ); - } + debugLogger.debug( + `[ControlDispatcher] Cancelled incoming request: ${requestId}`, + ); } } else { // Cancel ALL pending incoming requests @@ -199,11 +198,9 @@ export class ControlDispatcher implements IPendingRequestRegistry { } } - if (this.context.debugMode) { - console.error( - `[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`, - ); - } + debugLogger.debug( + `[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`, + ); } } @@ -222,7 +219,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { const requestIds = Array.from(this.pendingOutgoingRequests.keys()); if (this.context.debugMode) { - console.error( + debugLogger.debug( `[ControlDispatcher] Input closed, rejecting ${requestIds.length} pending outgoing requests`, ); } @@ -241,9 +238,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { * Stops all pending requests and cleans up all controllers */ shutdown(): void { - if (this.context.debugMode) { - console.error('[ControlDispatcher] Shutting down'); - } + debugLogger.debug('[ControlDispatcher] Shutting down'); // Cancel all incoming requests for (const [ @@ -354,18 +349,16 @@ export class ControlDispatcher implements IPendingRequestRegistry { while (this.pendingIncomingRequests.size > 0) { if (Date.now() - startTime > timeoutMs) { - if (this.context.debugMode) { - console.error( - `[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`, - ); - } + debugLogger.warn( + `[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`, + ); break; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } - if (this.context.debugMode && this.pendingIncomingRequests.size === 0) { - console.error('[ControlDispatcher] All incoming requests completed'); + if (this.pendingIncomingRequests.size === 0) { + debugLogger.debug('[ControlDispatcher] All incoming requests completed'); } } diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index 9a25ab9cf..8d5d5c545 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -16,6 +16,8 @@ */ import { randomUUID } from 'node:crypto'; +import type { DebugLogger } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { IControlContext } from '../ControlContext.js'; import type { ControlRequestPayload, @@ -57,6 +59,7 @@ export abstract class BaseController { protected context: IControlContext; protected registry: IPendingRequestRegistry; protected controllerName: string; + protected debugLogger: DebugLogger; constructor( context: IControlContext, @@ -66,6 +69,7 @@ export abstract class BaseController { this.context = context; this.registry = registry; this.controllerName = controllerName; + this.debugLogger = createDebugLogger(); } /** @@ -83,9 +87,9 @@ export abstract class BaseController { const timeoutId = setTimeout(() => { requestAbortController.abort(); this.registry.deregisterIncomingRequest(requestId); - if (this.context.debugMode) { - console.error(`[${this.controllerName}] Request timeout: ${requestId}`); - } + this.debugLogger.warn( + `[${this.controllerName}] Request timeout: ${requestId}`, + ); }, DEFAULT_REQUEST_TIMEOUT_MS); // Register with central registry @@ -141,11 +145,9 @@ export abstract class BaseController { const abortHandler = () => { this.registry.deregisterOutgoingRequest(requestId); reject(new Error('Request aborted')); - if (this.context.debugMode) { - console.error( - `[${this.controllerName}] Outgoing request aborted: ${requestId}`, - ); - } + this.debugLogger.warn( + `[${this.controllerName}] Outgoing request aborted: ${requestId}`, + ); }; if (signal) { @@ -159,11 +161,9 @@ export abstract class BaseController { } this.registry.deregisterOutgoingRequest(requestId); reject(new Error('Control request timeout')); - if (this.context.debugMode) { - console.error( - `[${this.controllerName}] Outgoing request timeout: ${requestId}`, - ); - } + this.debugLogger.warn( + `[${this.controllerName}] Outgoing request timeout: ${requestId}`, + ); }, timeoutMs); // Wrap resolve/reject to clean up abort listener diff --git a/packages/cli/src/nonInteractive/control/controllers/hookController.ts b/packages/cli/src/nonInteractive/control/controllers/hookController.ts index 1043b7b8c..df6eb4c0e 100644 --- a/packages/cli/src/nonInteractive/control/controllers/hookController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/hookController.ts @@ -42,9 +42,9 @@ export class HookController extends BaseController { private async handleHookCallback( payload: CLIHookCallbackRequest, ): Promise> { - if (this.context.debugMode) { - console.error(`[HookController] Hook callback: ${payload.callback_id}`); - } + this.debugLogger.debug( + `[HookController] Hook callback: ${payload.callback_id}`, + ); // Hook callback processing not yet implemented return { diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 2208404be..0cc402522 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -228,11 +228,9 @@ export class PermissionController extends BaseController { this.context.permissionMode = mode; this.context.config.setApprovalMode(mode as ApprovalMode); - if (this.context.debugMode) { - console.error( - `[PermissionController] Permission mode updated to: ${mode}`, - ); - } + this.debugLogger.info( + `[PermissionController] Permission mode updated to: ${mode}`, + ); return { status: 'updated', mode }; } @@ -463,12 +461,10 @@ export class PermissionController extends BaseController { ); } } catch (error) { - if (this.context.debugMode) { - console.error( - '[PermissionController] Outgoing permission failed:', - error, - ); - } + this.debugLogger.error( + '[PermissionController] Outgoing permission failed:', + error, + ); // Extract error message const errorMessage = diff --git a/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts index 5d0264fbb..058226c09 100644 --- a/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts @@ -82,12 +82,10 @@ export class SdkMcpController extends BaseController { serverName: string, message: JSONRPCMessage, ): Promise { - if (this.context.debugMode) { - console.error( - `[SdkMcpController] Sending MCP message to SDK server '${serverName}':`, - JSON.stringify(message), - ); - } + this.debugLogger.debug( + `[SdkMcpController] Sending MCP message to SDK server '${serverName}':`, + JSON.stringify(message), + ); // Send control request to SDK with the MCP message const response = await this.sendControlRequest( @@ -110,12 +108,10 @@ export class SdkMcpController extends BaseController { ); } - if (this.context.debugMode) { - console.error( - `[SdkMcpController] Received MCP response from SDK server '${serverName}':`, - JSON.stringify(mcpResponse), - ); - } + this.debugLogger.debug( + `[SdkMcpController] Received MCP response from SDK server '${serverName}':`, + JSON.stringify(mcpResponse), + ); return mcpResponse; } diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 824858aa3..06923e963 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -22,11 +22,14 @@ import type { } from '../../types.js'; import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js'; import { + createDebugLogger, MCPServerConfig, AuthProviderType, type MCPOAuthConfig, } from '@qwen-code/qwen-code-core'; +const debugLogger = createDebugLogger('SYSTEM_CONTROLLER'); + export class SystemController extends BaseController { /** * Handle system control requests @@ -122,18 +125,14 @@ export class SystemController extends BaseController { if (sdkServerCount > 0) { try { this.context.config.addMcpServers(sdkServers); - if (this.context.debugMode) { - console.error( - `[SystemController] Added ${sdkServerCount} SDK MCP servers to config`, - ); - } + debugLogger.debug( + `[SystemController] Added ${sdkServerCount} SDK MCP servers to config`, + ); } catch (error) { - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to add SDK MCP servers:', - error, - ); - } + debugLogger.error( + '[SystemController] Failed to add SDK MCP servers:', + error, + ); } } } @@ -158,18 +157,14 @@ export class SystemController extends BaseController { if (externalCount > 0) { try { this.context.config.addMcpServers(externalServers); - if (this.context.debugMode) { - console.error( - `[SystemController] Added ${externalCount} external MCP servers to config`, - ); - } + debugLogger.debug( + `[SystemController] Added ${externalCount} external MCP servers to config`, + ); } catch (error) { - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to add external MCP servers:', - error, - ); - } + debugLogger.error( + '[SystemController] Failed to add external MCP servers:', + error, + ); } } } @@ -178,29 +173,23 @@ export class SystemController extends BaseController { try { this.context.config.setSessionSubagents(payload.agents); - if (this.context.debugMode) { - console.error( - `[SystemController] Added ${payload.agents.length} session subagents to config`, - ); - } + debugLogger.debug( + `[SystemController] Added ${payload.agents.length} session subagents to config`, + ); } catch (error) { - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to add session subagents:', - error, - ); - } + debugLogger.error( + '[SystemController] Failed to add session subagents:', + error, + ); } } // Build capabilities for response const capabilities = this.buildControlCapabilities(); - if (this.context.debugMode) { - console.error( - `[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`, - ); - } + debugLogger.debug( + `[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`, + ); return { subtype: 'initialize', @@ -234,11 +223,9 @@ export class SystemController extends BaseController { config?: CLIMcpServerConfig, ): MCPServerConfig | null { if (!config || typeof config !== 'object') { - if (this.context.debugMode) { - console.error( - `[SystemController] Ignoring invalid MCP server config for '${serverName}'`, - ); - } + debugLogger.warn( + `[SystemController] Ignoring invalid MCP server config for '${serverName}'`, + ); return null; } @@ -282,11 +269,9 @@ export class SystemController extends BaseController { case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION: return value; default: - if (this.context.debugMode) { - console.error( - `[SystemController] Unsupported authProviderType '${value}', skipping`, - ); - } + debugLogger.warn( + `[SystemController] Unsupported authProviderType '${value}', skipping`, + ); return undefined; } } @@ -326,14 +311,10 @@ export class SystemController extends BaseController { // Abort the main signal to cancel ongoing operations if (this.context.abortSignal && !this.context.abortSignal.aborted) { // Note: We can't directly abort the signal, but the onInterrupt callback should handle this - if (this.context.debugMode) { - console.error('[SystemController] Interrupt signal triggered'); - } + debugLogger.debug('[SystemController] Interrupt signal triggered'); } - if (this.context.debugMode) { - console.error('[SystemController] Interrupt handled'); - } + debugLogger.debug('[SystemController] Interrupt handled'); return { subtype: 'interrupt' }; } @@ -362,9 +343,7 @@ export class SystemController extends BaseController { // Attempt to set the model using config await this.context.config.setModel(model); - if (this.context.debugMode) { - console.error(`[SystemController] Model switched to: ${model}`); - } + debugLogger.info(`[SystemController] Model switched to: ${model}`); return { subtype: 'set_model', @@ -374,12 +353,10 @@ export class SystemController extends BaseController { const errorMessage = error instanceof Error ? error.message : 'Failed to set model'; - if (this.context.debugMode) { - console.error( - `[SystemController] Failed to set model ${model}:`, - error, - ); - } + debugLogger.error( + `[SystemController] Failed to set model ${model}:`, + error, + ); throw new Error(errorMessage); } @@ -431,12 +408,10 @@ export class SystemController extends BaseController { return []; } - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to load slash commands:', - error, - ); - } + debugLogger.error( + '[SystemController] Failed to load slash commands:', + error, + ); return []; } } diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 56fd7b3e0..e163f516a 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -18,7 +18,6 @@ import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; import { ControlDispatcher } from './control/ControlDispatcher.js'; import { ControlContext } from './control/ControlContext.js'; import { ControlService } from './control/ControlService.js'; -import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; const runNonInteractiveMock = vi.fn(); @@ -47,10 +46,6 @@ vi.mock('./control/ControlService.js', () => ({ ControlService: vi.fn(), })); -vi.mock('../ui/utils/ConsolePatcher.js', () => ({ - ConsolePatcher: vi.fn(), -})); - interface ConfigOverrides { getSessionId?: () => string; getModel?: () => string; @@ -160,24 +155,11 @@ describe('runNonInteractiveStreamJson', () => { createSendSdkMcpMessage: ReturnType; }; }; - let mockConsolePatcher: { - patch: ReturnType; - cleanup: ReturnType; - }; - beforeEach(() => { config = createConfig(); runNonInteractiveMock.mockReset(); // Setup mocks - mockConsolePatcher = { - patch: vi.fn(), - cleanup: vi.fn(), - }; - (ConsolePatcher as unknown as ReturnType).mockImplementation( - () => mockConsolePatcher, - ); - mockOutputAdapter = { emitResult: vi.fn(), } as { @@ -236,9 +218,7 @@ describe('runNonInteractiveStreamJson', () => { await runNonInteractiveStreamJson(config, ''); - expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1); expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest); - expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1); }); it('processes user message when received as first message', async () => { @@ -489,8 +469,6 @@ describe('runNonInteractiveStreamJson', () => { await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow( 'Stream error', ); - - expect(mockConsolePatcher.cleanup).toHaveBeenCalled(); }); it('stops processing when abort signal is triggered', async () => { @@ -567,9 +545,6 @@ describe('runNonInteractiveStreamJson', () => { }; await runNonInteractiveStreamJson(config, ''); - - expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1); - expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1); }); it('cleans up output adapter on completion', async () => { @@ -598,7 +573,5 @@ describe('runNonInteractiveStreamJson', () => { }; await runNonInteractiveStreamJson(config, ''); - - expect(mockConsolePatcher.cleanup).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 0f22121f0..ae04eb642 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -8,6 +8,7 @@ import type { Config, ConfigInitializeOptions, } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; import { ControlContext } from './control/ControlContext.js'; @@ -32,7 +33,8 @@ import { } from './types.js'; import { createMinimalSettings } from '../config/settings.js'; import { runNonInteractive } from '../nonInteractiveCli.js'; -import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_SESSION'); class Session { private userMessageQueue: CLIUserMessage[] = []; @@ -46,7 +48,6 @@ class Session { private dispatcher: ControlDispatcher | null = null; private controlService: ControlService | null = null; private controlSystemEnabled: boolean | null = null; - private debugMode: boolean; private shutdownHandler: (() => void) | null = null; private initialPrompt: CLIUserMessage | null = null; private processingPromise: Promise | null = null; @@ -62,7 +63,6 @@ class Session { constructor(config: Config, initialPrompt?: CLIUserMessage) { this.config = config; this.sessionId = config.getSessionId(); - this.debugMode = config.getDebugMode(); this.abortController = new AbortController(); this.initialPrompt = initialPrompt ?? null; @@ -105,17 +105,13 @@ class Session { return; } - if (this.debugMode) { - console.error('[Session] Initializing config'); - } + debugLogger.debug('[Session] Initializing config'); try { await this.config.initialize(options); this.configInitialized = true; } catch (error) { - if (this.debugMode) { - console.error('[Session] Failed to initialize config:', error); - } + debugLogger.error('[Session] Failed to initialize config:', error); throw error; } } @@ -125,9 +121,7 @@ class Session { */ private completeInitialization(): void { if (this.initializationResolve) { - if (this.debugMode) { - console.error('[Session] Initialization complete'); - } + debugLogger.debug('[Session] Initialization complete'); this.initializationResolve(); this.initializationResolve = null; this.initializationReject = null; @@ -139,9 +133,7 @@ class Session { */ private failInitialization(error: Error): void { if (this.initializationReject) { - if (this.debugMode) { - console.error('[Session] Initialization failed:', error); - } + debugLogger.error('[Session] Initialization failed:', error); this.initializationReject(error); this.initializationResolve = null; this.initializationReject = null; @@ -213,11 +205,9 @@ class Session { return; } - if (this.debugMode) { - console.error( - '[Session] Ignoring non-initialize control request during initialization', - ); - } + debugLogger.debug( + '[Session] Ignoring non-initialize control request during initialization', + ); return; } @@ -254,9 +244,7 @@ class Session { // Initialization complete! this.completeInitialization(); } catch (error) { - if (this.debugMode) { - console.error('[Session] SDK mode initialization failed:', error); - } + debugLogger.error('[Session] SDK mode initialization failed:', error); this.failInitialization( error instanceof Error ? error : new Error(String(error)), ); @@ -281,9 +269,7 @@ class Session { // Enqueue the first user message for processing this.enqueueUserMessage(userMessage); } catch (error) { - if (this.debugMode) { - console.error('[Session] Direct mode initialization failed:', error); - } + debugLogger.error('[Session] Direct mode initialization failed:', error); this.failInitialization( error instanceof Error ? error : new Error(String(error)), ); @@ -297,18 +283,14 @@ class Session { private handleControlRequestAsync(request: CLIControlRequest): void { const dispatcher = this.getDispatcher(); if (!dispatcher) { - if (this.debugMode) { - console.error('[Session] Control system not enabled'); - } + debugLogger.warn('[Session] Control system not enabled'); return; } // Fire-and-forget: dispatch runs concurrently // The dispatcher's pendingIncomingRequests tracks completion void dispatcher.dispatch(request).catch((error) => { - if (this.debugMode) { - console.error('[Session] Control request dispatch error:', error); - } + debugLogger.error('[Session] Control request dispatch error:', error); // Error response is already sent by dispatcher.dispatch() }); } @@ -338,9 +320,7 @@ class Session { private async processUserMessage(userMessage: CLIUserMessage): Promise { const input = extractUserMessageText(userMessage); if (!input) { - if (this.debugMode) { - console.error('[Session] No text content in user message'); - } + debugLogger.debug('[Session] No text content in user message'); return; } @@ -362,9 +342,7 @@ class Session { }, ); } catch (error) { - if (this.debugMode) { - console.error('[Session] Query execution error:', error); - } + debugLogger.error('[Session] Query execution error:', error); } } @@ -382,9 +360,7 @@ class Session { try { await this.processUserMessage(userMessage); } catch (error) { - if (this.debugMode) { - console.error('[Session] Error processing user message:', error); - } + debugLogger.error('[Session] Error processing user message:', error); this.emitErrorResult(error); } } @@ -430,18 +406,14 @@ class Session { } private handleInterrupt(): void { - if (this.debugMode) { - console.error('[Session] Interrupt requested'); - } + debugLogger.info('[Session] Interrupt requested'); this.abortController.abort(); this.abortController = new AbortController(); } private setupSignalHandlers(): void { this.shutdownHandler = () => { - if (this.debugMode) { - console.error('[Session] Shutdown signal received'); - } + debugLogger.info('[Session] Shutdown signal received'); this.isShuttingDown = true; this.abortController.abort(); }; @@ -458,16 +430,17 @@ class Session { try { await this.waitForInitialization(); } catch (error) { - if (this.debugMode) { - console.error('[Session] Initialization error during shutdown:', error); - } + debugLogger.error( + '[Session] Initialization error during shutdown:', + error, + ); } // 2. Wait for all control request handlers using dispatcher's tracking if (this.dispatcher) { const pendingCount = this.dispatcher.getPendingIncomingRequestCount(); - if (pendingCount > 0 && this.debugMode) { - console.error( + if (pendingCount > 0) { + debugLogger.debug( `[Session] Waiting for ${pendingCount} pending control request handlers`, ); } @@ -476,23 +449,17 @@ class Session { // 3. Wait for user message processing queue while (this.processingPromise) { - if (this.debugMode) { - console.error('[Session] Waiting for user message processing'); - } + debugLogger.debug('[Session] Waiting for user message processing'); try { await this.processingPromise; } catch (error) { - if (this.debugMode) { - console.error('[Session] Error in user message processing:', error); - } + debugLogger.error('[Session] Error in user message processing:', error); } } } private async shutdown(): Promise { - if (this.debugMode) { - console.error('[Session] Shutting down'); - } + debugLogger.debug('[Session] Shutting down'); this.isShuttingDown = true; @@ -528,9 +495,7 @@ class Session { */ async run(): Promise { try { - if (this.debugMode) { - console.error('[Session] Starting session', this.sessionId); - } + debugLogger.info('[Session] Starting session', this.sessionId); // Handle initial prompt if provided (fire-and-forget) if (this.initialPrompt !== null) { @@ -571,18 +536,16 @@ class Session { } else if (isCLIUserMessage(message)) { // User messages are enqueued, processing runs separately this.enqueueUserMessage(message as CLIUserMessage); - } else if (this.debugMode) { - if ( - !isCLIAssistantMessage(message) && - !isCLISystemMessage(message) && - !isCLIResultMessage(message) && - !isCLIPartialAssistantMessage(message) - ) { - console.error( - '[Session] Unknown message type:', - JSON.stringify(message, null, 2), - ); - } + } else if ( + !isCLIAssistantMessage(message) && + !isCLISystemMessage(message) && + !isCLIResultMessage(message) && + !isCLIPartialAssistantMessage(message) + ) { + debugLogger.warn( + '[Session] Unknown message type:', + JSON.stringify(message, null, 2), + ); } if (this.isShuttingDown) { @@ -590,9 +553,7 @@ class Session { } } } catch (streamError) { - if (this.debugMode) { - console.error('[Session] Stream reading error:', streamError); - } + debugLogger.error('[Session] Stream reading error:', streamError); throw streamError; } @@ -607,9 +568,7 @@ class Session { await this.waitForAllPendingWork(); await this.shutdown(); } catch (error) { - if (this.debugMode) { - console.error('[Session] Error:', error); - } + debugLogger.error('[Session] Error:', error); await this.shutdown(); throw error; } finally { @@ -647,29 +606,20 @@ export async function runNonInteractiveStreamJson( config: Config, input: string, ): Promise { - const consolePatcher = new ConsolePatcher({ - debugMode: config.getDebugMode(), - }); - consolePatcher.patch(); - - try { - let initialPrompt: CLIUserMessage | undefined = undefined; - if (input && input.trim().length > 0) { - const sessionId = config.getSessionId(); - initialPrompt = { - type: 'user', - session_id: sessionId, - message: { - role: 'user', - content: input.trim(), - }, - parent_tool_use_id: null, - }; - } - - const manager = new Session(config, initialPrompt); - await manager.run(); - } finally { - consolePatcher.cleanup(); + let initialPrompt: CLIUserMessage | undefined = undefined; + if (input && input.trim().length > 0) { + const sessionId = config.getSessionId(); + initialPrompt = { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: input.trim(), + }, + parent_tool_use_id: null, + }; } + + const manager = new Session(config, initialPrompt); + await manager.run(); } diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 34598b70d..2931118fc 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -66,7 +66,6 @@ describe('runNonInteractive', () => { let mockToolRegistry: ToolRegistry; let mockCoreExecuteToolCall: Mock; let mockShutdownTelemetry: Mock; - let consoleErrorSpy: MockInstance; let processStdoutSpy: MockInstance; let processStderrSpy: MockInstance; let mockGeminiClient: { @@ -83,7 +82,6 @@ describe('runNonInteractive', () => { getCommands: mockGetCommands, }); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); @@ -360,9 +358,6 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); - // Enable debug mode so handleToolError logs to console.error - (mockConfig.getDebugMode as Mock).mockReturnValue(true); - await runNonInteractive( mockConfig, mockSettings, @@ -371,9 +366,6 @@ describe('runNonInteractive', () => { ); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error executing tool errorTool: Execution failed', - ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, @@ -443,9 +435,6 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); - // Enable debug mode so handleToolError logs to console.error - (mockConfig.getDebugMode as Mock).mockReturnValue(true); - await runNonInteractive( mockConfig, mockSettings, @@ -454,9 +443,6 @@ describe('runNonInteractive', () => { ); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', - ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(processStdoutSpy).toHaveBeenCalledWith( "Sorry, I can't find that tool.", @@ -738,11 +724,6 @@ describe('runNonInteractive', () => { throw testError; }); - // Mock console.error to capture JSON error output - const consoleErrorJsonSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - let thrownError: Error | null = null; try { await runNonInteractive( @@ -760,19 +741,18 @@ describe('runNonInteractive', () => { // Should throw because of mocked process.exit expect(thrownError?.message).toBe('process.exit(1) called'); - expect(consoleErrorJsonSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'Error', - message: 'Invalid input provided', - code: 1, - }, + const jsonError = JSON.stringify( + { + error: { + type: 'Error', + message: 'Invalid input provided', + code: 1, }, - null, - 2, - ), + }, + null, + 2, ); + expect(processStderrSpy).toHaveBeenCalledWith(`${jsonError}\n`); }); it('should handle API errors in text mode and exit with error code', async () => { @@ -830,11 +810,6 @@ describe('runNonInteractive', () => { throw fatalError; }); - // Mock console.error to capture JSON error output - const consoleErrorJsonSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - let thrownError: Error | null = null; try { await runNonInteractive( @@ -852,19 +827,18 @@ describe('runNonInteractive', () => { // Should throw because of mocked process.exit with custom exit code expect(thrownError?.message).toBe('process.exit(42) called'); - expect(consoleErrorJsonSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'FatalInputError', - message: 'Invalid command syntax provided', - code: 42, - }, + const jsonError = JSON.stringify( + { + error: { + type: 'FatalInputError', + message: 'Invalid command syntax provided', + code: 42, }, - null, - 2, - ), + }, + null, + 2, ); + expect(processStderrSpy).toHaveBeenCalledWith(`${jsonError}\n`); }); it('should execute a slash command that returns a prompt', async () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 85c050bce..b595d61f1 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -18,6 +18,7 @@ import { InputFormat, uiTelemetryService, parseAndFormatApiError, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -34,6 +35,8 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); import { normalizePartList, extractPartsFromUserMessage, @@ -145,9 +148,7 @@ export async function runNonInteractive( // Setup signal handlers for graceful shutdown const shutdownHandler = () => { - if (config.getDebugMode()) { - console.error('[runNonInteractive] Shutdown signal received'); - } + debugLogger.debug('[runNonInteractive] Shutdown signal received'); abortController.abort(); }; @@ -408,7 +409,7 @@ export async function runNonInteractive( process.removeListener('SIGINT', shutdownHandler); process.removeListener('SIGTERM', shutdownHandler); if (isTelemetrySdkInitialized()) { - await shutdownTelemetry(config); + await shutdownTelemetry(); } } }); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 8e2e28dd4..a26f4dbca 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -10,6 +10,7 @@ import { Logger, uiTelemetryService, type Config, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import { CommandService } from './services/CommandService.js'; import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; @@ -25,6 +26,8 @@ import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; import { t } from './i18n/index.js'; +const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS'); + /** * Built-in commands that are allowed in non-interactive modes (CLI and ACP). * Only safe, read-only commands that don't require interactive UI. @@ -377,7 +380,7 @@ export const getAvailableCommands = async ( return filteredCommands.filter((cmd) => !cmd.hidden); } catch (error) { // Handle errors gracefully - log and return empty array - console.error('Error loading available commands:', error); + debugLogger.error('Error loading available commands:', error); return []; } }; diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index e2d5b9f58..51f962753 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -139,10 +139,6 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(1); expect(commands).toEqual([mockCommandA]); - expect(console.debug).toHaveBeenCalledWith( - 'A command loader failed:', - error, - ); }); it('getCommands should return a readonly array that cannot be mutated', async () => { diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 5f1e09d50..41086dac1 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -6,6 +6,9 @@ import type { SlashCommand } from '../ui/commands/types.js'; import type { ICommandLoader } from './types.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('CLI_COMMANDS'); /** * Orchestrates the discovery and loading of all slash commands for the CLI. @@ -57,7 +60,7 @@ export class CommandService { if (result.status === 'fulfilled') { allCommands.push(...result.value); } else { - console.debug('A command loader failed:', result.reason); + debugLogger.debug('A command loader failed:', result.reason); } } diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index ef81f9e69..f92004afb 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -11,7 +11,11 @@ import toml from '@iarna/toml'; import { glob } from 'glob'; import { z } from 'zod'; import type { Config } from '@qwen-code/qwen-code-core'; -import { EXTENSIONS_CONFIG_FILENAME, Storage } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + EXTENSIONS_CONFIG_FILENAME, + Storage, +} from '@qwen-code/qwen-code-core'; import type { ICommandLoader } from './types.js'; import { parseMarkdownCommand, @@ -28,6 +32,8 @@ interface CommandDirectory { extensionName?: string; } +const debugLogger = createDebugLogger('FILE_COMMAND_LOADER'); + /** * Defines the Zod schema for a command definition file. This serves as the * single source of truth for both validation and type inference. @@ -129,7 +135,7 @@ export class FileCommandLoader implements ICommandLoader { const isAbortError = error instanceof Error && error.name === 'AbortError'; if (!isEnoent && !isAbortError) { - console.error( + debugLogger.error( `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, error, ); @@ -214,7 +220,10 @@ export class FileCommandLoader implements ICommandLoader { } } } catch (error) { - console.warn(`Failed to read extension config for ${ext.name}:`, error); + debugLogger.warn( + `Failed to read extension config for ${ext.name}:`, + error, + ); } // Default fallback: use 'commands' directory @@ -246,7 +255,7 @@ export class FileCommandLoader implements ICommandLoader { try { fileContent = await fs.readFile(filePath, 'utf-8'); } catch (error: unknown) { - console.error( + debugLogger.error( `[FileCommandLoader] Failed to read file ${filePath}:`, error instanceof Error ? error.message : String(error), ); @@ -257,7 +266,7 @@ export class FileCommandLoader implements ICommandLoader { try { parsed = toml.parse(fileContent); } catch (error: unknown) { - console.error( + debugLogger.error( `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, error instanceof Error ? error.message : String(error), ); @@ -267,7 +276,7 @@ export class FileCommandLoader implements ICommandLoader { const validationResult = TomlCommandDefSchema.safeParse(parsed); if (!validationResult.success) { - console.error( + debugLogger.error( `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, validationResult.error.flatten(), ); @@ -302,7 +311,7 @@ export class FileCommandLoader implements ICommandLoader { try { fileContent = await fs.readFile(filePath, 'utf-8'); } catch (error: unknown) { - console.error( + debugLogger.error( `[FileCommandLoader] Failed to read file ${filePath}:`, error instanceof Error ? error.message : String(error), ); @@ -313,7 +322,7 @@ export class FileCommandLoader implements ICommandLoader { try { parsed = parseMarkdownCommand(fileContent); } catch (error: unknown) { - console.error( + debugLogger.error( `[FileCommandLoader] Failed to parse Markdown file ${filePath}:`, error instanceof Error ? error.message : String(error), ); @@ -323,7 +332,7 @@ export class FileCommandLoader implements ICommandLoader { const validationResult = MarkdownCommandDefSchema.safeParse(parsed); if (!validationResult.success) { - console.error( + debugLogger.error( `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, validationResult.error.flatten(), ); diff --git a/packages/cli/src/services/command-factory.ts b/packages/cli/src/services/command-factory.ts index 95eec70c3..1720401e1 100644 --- a/packages/cli/src/services/command-factory.ts +++ b/packages/cli/src/services/command-factory.ts @@ -10,6 +10,7 @@ */ import path from 'node:path'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { CommandContext, SlashCommand, @@ -37,6 +38,8 @@ export interface CommandDefinition { description?: string; } +const debugLogger = createDebugLogger('COMMAND_FACTORY'); + /** * Creates a SlashCommand from a parsed command definition. * This function is used by both TOML and Markdown command loaders. @@ -113,7 +116,7 @@ export function createSlashCommandFromDefinition( _args: string, ): Promise => { if (!context.invocation) { - console.error( + debugLogger.error( `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, ); return { diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts index 3d8737b1f..118b1c1e0 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -7,6 +7,7 @@ import { flatMapTextParts, readPathFromWorkspace, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import type { CommandContext } from '../../ui/commands/types.js'; import { MessageType } from '../../ui/types.js'; @@ -17,6 +18,8 @@ import { } from './types.js'; import { extractInjections } from './injectionParser.js'; +const debugLogger = createDebugLogger('AT_FILE_PROCESSOR'); + export class AtFileProcessor implements IPromptProcessor { constructor(private readonly commandName?: string) {} @@ -68,7 +71,7 @@ export class AtFileProcessor implements IPromptProcessor { error instanceof Error ? error.message : String(error); const uiMessage = `Failed to inject content for '@{${pathStr}}': ${message}`; - console.error( + debugLogger.error( `[AtFileProcessor] ${uiMessage}. Leaving placeholder in prompt.`, ); context.ui.addItem( diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d8883e673..be09fe52f 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -66,7 +66,6 @@ describe('App', () => { ); expect(lastFrame()).toContain('MainContent'); - expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); }); @@ -98,7 +97,6 @@ describe('App', () => { ); expect(lastFrame()).toContain('MainContent'); - expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('DialogManager'); }); @@ -157,6 +155,6 @@ describe('App', () => { , ); - expect(lastFrame()).toContain('MainContent\nNotifications\nComposer'); + expect(lastFrame()).toContain('MainContent\nComposer'); }); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5e035d2be..1edec79f9 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -62,7 +62,6 @@ vi.mock('./hooks/useEditorSettings.js'); vi.mock('./hooks/useSettingsCommand.js'); vi.mock('./hooks/useModelCommand.js'); vi.mock('./hooks/slashCommandProcessor.js'); -vi.mock('./hooks/useConsoleMessages.js'); vi.mock('./hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), })); @@ -85,7 +84,6 @@ vi.mock('./hooks/useLogger.js'); // Mock external utilities vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); -vi.mock('./utils/ConsolePatcher.js'); vi.mock('../utils/cleanup.js'); import { useHistory } from './hooks/useHistoryManager.js'; @@ -95,7 +93,6 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -125,7 +122,6 @@ describe('AppContainer State Management', () => { const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; - const mockedUseConsoleMessages = useConsoleMessages as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; @@ -206,11 +202,6 @@ describe('AppContainer State Management', () => { shellConfirmationRequest: null, confirmationRequest: null, }); - mockedUseConsoleMessages.mockReturnValue({ - consoleMessages: [], - handleNewMessage: vi.fn(), - clearConsoleMessages: vi.fn(), - }); mockedUseGeminiStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 316521d99..dd1e53a64 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -34,6 +34,7 @@ import { type IdeContext, IdeClient, ideContextStore, + createDebugLogger, getErrorMessage, getAllGeminiMdFilenames, ShellExecutionService, @@ -55,7 +56,6 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdin, useStdout } from 'ink'; @@ -63,6 +63,7 @@ import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; import { basename } from 'node:path'; import { computeWindowTitle } from '../utils/windowTitle.js'; +import { clearScreen } from '../utils/stdioHelpers.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; @@ -80,10 +81,8 @@ import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; import { type CommandMigrationNudgeResult } from './CommandFormatMigrationNudge.js'; import { useCommandMigration } from './hooks/useCommandMigration.js'; import { migrateTomlCommands } from '../services/command-migration-tool.js'; -import { appEvents, AppEvent } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; -import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; @@ -111,6 +110,7 @@ import { } from '../commands/extensions/consent.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; +const debugLogger = createDebugLogger('APP_CONTAINER'); function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -313,21 +313,6 @@ export const AppContainer = (props: AppContainerProps) => { return () => clearInterval(interval); }, [config, currentModel, getCurrentModel]); - const { - consoleMessages, - handleNewMessage, - clearConsoleMessages: clearConsoleMessagesState, - } = useConsoleMessages(); - - useEffect(() => { - const consolePatcher = new ConsolePatcher({ - onNewMessage: handleNewMessage, - debugMode: config.getDebugMode(), - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - }, [handleNewMessage, config]); - // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = @@ -607,10 +592,13 @@ export const AppContainer = (props: AppContainerProps) => { [visionSwitchResolver], ); - // onDebugMessage should log to console, not update footer debugMessage - const onDebugMessage = useCallback((message: string) => { - console.debug(message); - }, []); + // onDebugMessage should log to debug logfile, not update footer debugMessage + const onDebugMessage = useCallback( + (message: string) => { + config.getDebugLogger().debug(message); + }, + [config], + ); const performMemoryRefresh = useCallback(async () => { historyManager.addItem( @@ -626,7 +614,6 @@ export const AppContainer = (props: AppContainerProps) => { settings.merged.context?.loadFromIncludeDirectories ? config.getWorkspaceContext().getDirectories() : [], - config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), config.isTrustedFolder(), @@ -648,14 +635,12 @@ export const AppContainer = (props: AppContainerProps) => { }, Date.now(), ); - if (config.getDebugMode()) { - console.log( - `[DEBUG] Refreshed memory content in config: ${memoryContent.substring( - 0, - 200, - )}...`, - ); - } + debugLogger.debug( + `[DEBUG] Refreshed memory content in config: ${memoryContent.substring( + 0, + 200, + )}...`, + ); } catch (error) { const errorMessage = getErrorMessage(error); historyManager.addItem( @@ -665,7 +650,7 @@ export const AppContainer = (props: AppContainerProps) => { }, Date.now(), ); - console.error('Error refreshing memory:', error); + debugLogger.error('Error refreshing memory:', error); } }, [config, historyManager, settings.merged]); @@ -769,10 +754,9 @@ export const AppContainer = (props: AppContainerProps) => { const handleClearScreen = useCallback(() => { historyManager.clearItems(); - clearConsoleMessagesState(); - console.clear(); + clearScreen(); refreshStatic(); - }, [historyManager, clearConsoleMessagesState, refreshStatic]); + }, [historyManager, refreshStatic]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -900,7 +884,6 @@ export const AppContainer = (props: AppContainerProps) => { setShowMigrationNudge: setShowCommandMigrationNudge, } = useCommandMigration(settings, config.storage); - const [showErrorDetails, setShowErrorDetails] = useState(false); const [showToolDescriptions, setShowToolDescriptions] = useState(false); @@ -951,28 +934,6 @@ export const AppContainer = (props: AppContainerProps) => { return unsubscribe; }, []); - useEffect(() => { - const openDebugConsole = () => { - setShowErrorDetails(true); - setConstrainHeight(false); - }; - appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); - - const logErrorHandler = (errorMessage: unknown) => { - handleNewMessage({ - type: 'error', - content: String(errorMessage), - count: 1, - }); - }; - appEvents.on(AppEvent.LogError, logErrorHandler); - - return () => { - appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); - appEvents.off(AppEvent.LogError, logErrorHandler); - }; - }, [handleNewMessage]); - const handleEscapePromptChange = useCallback((showPrompt: boolean) => { setShowEscapePrompt(showPrompt); }, []); @@ -1180,7 +1141,7 @@ export const AppContainer = (props: AppContainerProps) => { (key: Key) => { // Debug log keystrokes if enabled if (settings.merged.general?.debugKeystrokeLogging) { - console.log('[DEBUG] Keystroke:', JSON.stringify(key)); + debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key)); } if (keyMatchers[Command.QUIT](key)) { @@ -1214,9 +1175,7 @@ export const AppContainer = (props: AppContainerProps) => { setConstrainHeight(true); } - if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { - setShowErrorDetails((prev) => !prev); - } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { + if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { const newValue = !showToolDescriptions; setShowToolDescriptions(newValue); @@ -1244,7 +1203,6 @@ export const AppContainer = (props: AppContainerProps) => { [ constrainHeight, setConstrainHeight, - setShowErrorDetails, showToolDescriptions, setShowToolDescriptions, config, @@ -1303,22 +1261,6 @@ export const AppContainer = (props: AppContainerProps) => { stdout, ]); - const filteredConsoleMessages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - - // Computed values - const errorCount = useMemo( - () => - filteredConsoleMessages - .filter((msg) => msg.type === 'error') - .reduce((total, msg) => total + msg.count, 0), - [filteredConsoleMessages], - ); - const nightly = props.version.includes('nightly'); const dialogsVisible = @@ -1413,8 +1355,6 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, isTrustedFolder, constrainHeight, - showErrorDetails, - filteredConsoleMessages, ideContextState, showToolDescriptions, ctrlCPressedOnce, @@ -1428,7 +1368,6 @@ export const AppContainer = (props: AppContainerProps) => { showAutoAcceptIndicator, currentModel, contextFileNames, - errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, @@ -1506,8 +1445,6 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, isTrustedFolder, constrainHeight, - showErrorDetails, - filteredConsoleMessages, ideContextState, showToolDescriptions, ctrlCPressedOnce, @@ -1520,7 +1457,6 @@ export const AppContainer = (props: AppContainerProps) => { messageQueue, showAutoAcceptIndicator, contextFileNames, - errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index de7509040..b07531967 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -34,6 +34,12 @@ describe('copyCommand', () => { getGeminiClient: () => ({ getChat: mockGetChat, }), + getDebugLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), }, }, }); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 3b79dd489..421b0323b 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -48,7 +48,7 @@ export const copyCommand: SlashCommand = { }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.debug(message); + context.services.config?.getDebugLogger().debug(message); return { type: 'message', diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index f5c91b46b..1fcd83dd3 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -113,7 +113,6 @@ export const directoryCommand: SlashCommand = { ...config.getWorkspaceContext().getDirectories(), ...pathsToAdd, ], - config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), config.getFolderTrust(), diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index e13df24f7..132f92901 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -18,10 +18,12 @@ import { parseInstallSource, type ExtensionUpdateInfo, } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { SettingScope } from '../../config/settings.js'; import open from 'open'; import { extensionToOutputString } from '../../commands/extensions/utils.js'; +const debugLogger = createDebugLogger('EXTENSIONS_COMMAND'); const EXTENSION_EXPLORE_URL = { Gemini: 'https://geminicli.com/extensions/', ClaudeCode: 'https://claudemarketplaces.com/', @@ -240,7 +242,7 @@ async function updateAction(context: CommandContext, args: string) { async function installAction(context: CommandContext, args: string) { const extensionManager = context.services.config?.getExtensionManager(); if (!(extensionManager instanceof ExtensionManager)) { - console.error( + debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; @@ -297,7 +299,7 @@ async function installAction(context: CommandContext, args: string) { async function uninstallAction(context: CommandContext, args: string) { const extensionManager = context.services.config?.getExtensionManager(); if (!(extensionManager instanceof ExtensionManager)) { - console.error( + debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; @@ -357,7 +359,7 @@ function getEnableDisableContext( } | null { const extensionManager = context.services.config?.getExtensionManager(); if (!(extensionManager instanceof ExtensionManager)) { - console.error( + debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return null; @@ -479,7 +481,7 @@ async function enableAction(context: CommandContext, args: string) { async function detailAction(context: CommandContext, args: string) { const extensionManager = context.services.config?.getExtensionManager(); if (!(extensionManager instanceof ExtensionManager)) { - console.error( + debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index 56b08e1bb..6ddade4fb 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -28,6 +28,9 @@ import { resolveOutputLanguage, updateOutputLanguageFile, } from '../../utils/languageUtils.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('LANGUAGE_COMMAND'); /** * Gets the current LLM output language setting and its resolved value. @@ -100,7 +103,7 @@ async function setUiLanguage( try { services.settings.setValue(SettingScope.User, 'general.language', lang); } catch (error) { - console.warn('Failed to save language setting:', error); + debugLogger.warn('Failed to save language setting:', error); } } @@ -142,7 +145,7 @@ async function setOutputLanguage( settingValue, ); } catch (error) { - console.warn('Failed to save output language setting:', error); + debugLogger.warn('Failed to save output language setting:', error); } } diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index d9d2950b1..507444e5a 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -309,7 +309,6 @@ export const memoryCommand: SlashCommand = { config.shouldLoadMemoryFromIncludeDirectories() ? config.getWorkspaceContext().getDirectories() : [], - config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), config.getFolderTrust(), diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 6f0faae37..16a938bac 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -219,20 +219,13 @@ describe('updateGitignore', () => { }); it('handles permission errors gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - const fsModule = await import('node:fs'); const writeFileSpy = vi .spyOn(fsModule.promises, 'writeFile') .mockRejectedValue(new Error('Permission denied')); await expect(updateGitignore(scratchDir)).resolves.toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to update .gitignore:', - expect.any(Error), - ); writeFileSpy.mockRestore(); - consoleSpy.mockRestore(); }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index b12268edd..baba59b6c 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -21,6 +21,9 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; import { t } from '../../i18n/index.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('SETUP_GITHUB'); export const GITHUB_WORKFLOW_PATHS = [ 'qwen-dispatch/qwen-dispatch.yml', @@ -85,7 +88,7 @@ export async function updateGitignore(gitRepoRoot: string): Promise { } } } catch (error) { - console.debug('Failed to update .gitignore:', error); + debugLogger.debug('Failed to update .gitignore:', error); // Continue without failing the whole command } } @@ -112,7 +115,7 @@ export const setupGithubCommand: SlashCommand = { try { gitRepoRoot = getGitRepoRoot(); } catch (_error) { - console.debug(`Failed to get git repo root:`, _error); + debugLogger.debug(`Failed to get git repo root:`, _error); throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); @@ -128,7 +131,7 @@ export const setupGithubCommand: SlashCommand = { try { await fs.promises.mkdir(githubWorkflowsDir, { recursive: true }); } catch (_error) { - console.debug( + debugLogger.debug( `Failed to create ${githubWorkflowsDir} directory:`, _error, ); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 8e41a1ce9..c154a479e 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -14,6 +14,9 @@ import { MessageType, type HistoryItemSkillsList } from '../types.js'; import { t } from '../../i18n/index.js'; import { AsyncFzf } from 'fzf'; import type { SkillConfig } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('SKILLS_COMMAND'); export const skillsCommand: SlashCommand = { name: 'skills', @@ -123,7 +126,7 @@ async function getSkillMatches( .map((result) => skillMap.get(result.item)) .filter((skill): skill is SkillConfig => !!skill); } catch (error) { - console.error('[skillsCommand] Fuzzy match failed:', error); + debugLogger.error('[skillsCommand] Fuzzy match failed:', error); const lowerQuery = query.toLowerCase(); return skills.filter((skill) => skill.name.toLowerCase().startsWith(lowerQuery), diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index a12855e4c..1db02d6f9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -43,10 +43,6 @@ vi.mock('./ShellModeIndicator.js', () => ({ ShellModeIndicator: () => ShellModeIndicator, })); -vi.mock('./DetailedMessagesDisplay.js', () => ({ - DetailedMessagesDisplay: () => DetailedMessagesDisplay, -})); - vi.mock('./InputPrompt.js', () => ({ InputPrompt: () => InputPrompt, calculatePromptWidths: vi.fn(() => ({ @@ -60,10 +56,6 @@ vi.mock('./Footer.js', () => ({ Footer: () => Footer, })); -vi.mock('./ShowMoreLines.js', () => ({ - ShowMoreLines: () => ShowMoreLines, -})); - vi.mock('./QueuedMessageDisplay.js', () => ({ QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => { if (messageQueue.length === 0) { @@ -91,7 +83,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => contextFileNames: [], showAutoAcceptIndicator: ApprovalMode.DEFAULT, messageQueue: [], - showErrorDetails: false, constrainHeight: false, isInputActive: true, buffer: '', @@ -111,7 +102,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => ideContextState: null, geminiMdFileCount: 0, showToolDescriptions: false, - filteredConsoleMessages: [], sessionStats: { lastPromptTokenCount: 0, sessionTokenCount: 0, @@ -119,7 +109,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => }, branchName: 'main', debugMessage: '', - errorCount: 0, nightly: false, isTrustedFolder: true, ...overrides, @@ -354,31 +343,4 @@ describe('Composer', () => { expect(lastFrame()).toContain('Footer'); }); }); - - describe('Error Details Display', () => { - it('shows DetailedMessagesDisplay when showErrorDetails is true', () => { - const uiState = createMockUIState({ - showErrorDetails: true, - filteredConsoleMessages: [ - { level: 'error', message: 'Test error', timestamp: new Date() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any, - }); - - const { lastFrame } = renderComposer(uiState); - - expect(lastFrame()).toContain('DetailedMessagesDisplay'); - expect(lastFrame()).toContain('ShowMoreLines'); - }); - - it('does not show error details when showErrorDetails is false', () => { - const uiState = createMockUIState({ - showErrorDetails: false, - }); - - const { lastFrame } = renderComposer(uiState); - - expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); - }); - }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 30608a961..d0f5353f1 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,15 +5,12 @@ */ import { Box, useIsScreenReaderEnabled } from 'ink'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { LoadingIndicator } from './LoadingIndicator.js'; -import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { InputPrompt, calculatePromptWidths } from './InputPrompt.js'; +import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; -import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { KeyboardShortcuts } from './KeyboardShortcuts.js'; -import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; @@ -29,8 +26,6 @@ export const Composer = () => { const uiState = useUIState(); const uiActions = useUIActions(); const { vimEnabled } = useVimMode(); - const terminalWidth = process.stdout.columns; - const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const { showAutoAcceptIndicator } = uiState; @@ -46,12 +41,6 @@ export const Composer = () => { setShowSuggestions(visible); }, []); - // Use the container width of InputPrompt for width of DetailedMessagesDisplay - const { containerWidth } = useMemo( - () => calculatePromptWidths(uiState.terminalWidth), - [uiState.terminalWidth], - ); - return ( {!uiState.embeddedShellFocused && ( @@ -77,21 +66,6 @@ export const Composer = () => { - {uiState.showErrorDetails && ( - - - - - - - )} - {uiState.isFeedbackDialogOpen && } {uiState.isInputActive && ( diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx deleted file mode 100644 index 2f2f8a2a7..000000000 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; - -interface ConsoleSummaryDisplayProps { - errorCount: number; - // logCount is not currently in the plan to be displayed in summary -} - -export const ConsoleSummaryDisplay: React.FC = ({ - errorCount, -}) => { - if (errorCount === 0) { - return null; - } - - const errorIcon = '\u2716'; // Heavy multiplication x (✖) - - return ( - - {errorCount > 0 && ( - - {errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '} - (ctrl+o for details) - - )} - - ); -}; diff --git a/packages/cli/src/ui/components/DebugModeNotification.tsx b/packages/cli/src/ui/components/DebugModeNotification.tsx new file mode 100644 index 000000000..316cde9df --- /dev/null +++ b/packages/cli/src/ui/components/DebugModeNotification.tsx @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { Storage, isDebugLoggingDegraded } from '@qwen-code/qwen-code-core'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { theme } from '../semantic-colors.js'; + +/** + * Displays debug mode status and log file path when debug mode is enabled. + */ +export const DebugModeNotification = () => { + const config = useConfig(); + + if (!config.getDebugMode()) { + return null; + } + + const logPath = Storage.getDebugLogPath(config.getSessionId()); + const isDegraded = isDebugLoggingDegraded(); + + return ( + + Debug mode enabled + Logging to: {logPath} + {isDegraded && ( + + Warning: Debug logging is degraded (write failures occurred) + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx deleted file mode 100644 index b31d08800..000000000 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import type { ConsoleMessageItem } from '../types.js'; -import { MaxSizedBox } from './shared/MaxSizedBox.js'; - -interface DetailedMessagesDisplayProps { - messages: ConsoleMessageItem[]; - maxHeight: number | undefined; - width: number; - // debugMode is not needed here if App.tsx filters debug messages before passing them. - // If DetailedMessagesDisplay should handle filtering, add debugMode prop. -} - -export const DetailedMessagesDisplay: React.FC< - DetailedMessagesDisplayProps -> = ({ messages, maxHeight, width }) => { - if (messages.length === 0) { - return null; // Don't render anything if there are no messages - } - - const borderAndPadding = 4; - return ( - - - - Debug Console{' '} - (ctrl+o to close) - - - - {messages.map((msg, index) => { - let textColor = theme.text.primary; - let icon = '\u2139'; // Information source (ℹ) - - switch (msg.type) { - case 'warn': - textColor = theme.status.warning; - icon = '\u26A0'; // Warning sign (⚠) - break; - case 'error': - textColor = theme.status.error; - icon = '\u2716'; // Heavy multiplication x (✖) - break; - case 'debug': - textColor = theme.text.secondary; // Or theme.text.secondary - icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍) - break; - case 'log': - default: - // Default textColor and icon are already set - break; - } - - return ( - - {icon} - - {msg.content} - {msg.count && msg.count > 1 && ( - (x{msg.count}) - )} - - - ); - })} - - - ); -}; diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index cd77c230e..f2df5edaa 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -18,10 +18,15 @@ import { ScopeSelector } from './shared/ScopeSelector.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import type { EditorType } from '@qwen-code/qwen-code-core'; -import { isEditorAvailable } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + isEditorAvailable, +} from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; +const debugLogger = createDebugLogger('EDITOR_SETTINGS_DIALOG'); + interface EditorDialogProps { onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void; settings: LoadedSettings; @@ -61,7 +66,7 @@ export function EditorSettingsDialog({ ) : 0; if (editorIndex === -1) { - console.error(`Editor is not supported: ${currentPreference}`); + debugLogger.error(`Editor is not supported: ${currentPreference}`); editorIndex = 0; } diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index b55923a84..af81f6a5d 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -7,7 +7,6 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; @@ -25,20 +24,11 @@ export const Footer: React.FC = () => { const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const { - errorCount, - showErrorDetails, - promptTokenCount, - showAutoAcceptIndicator, - } = { - errorCount: uiState.errorCount, - showErrorDetails: uiState.showErrorDetails, + const { promptTokenCount, showAutoAcceptIndicator } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, showAutoAcceptIndicator: uiState.showAutoAcceptIndicator, }; - const showErrorIndicator = !showErrorDetails && errorCount > 0; - const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); @@ -103,13 +93,6 @@ export const Footer: React.FC = () => { ), }); } - if (showErrorIndicator) { - rightItems.push({ - key: 'errors', - node: , - }); - } - return ( { expect(frameText).toContain("Press 'r' to restart Gemini"); }); - it('renders a generic message and logs an error for NONE reason', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('renders a generic message for NONE reason', () => { const { lastFrame } = renderWithProviders( , ); const frameText = lastFrame(); expect(frameText).toContain('Workspace trust has changed.'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'IdeTrustChangeDialog rendered with unexpected reason "NONE"', - ); }); it('calls relaunchApp when "r" is pressed', () => { diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx index ba1288afc..6780792ef 100644 --- a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx +++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx @@ -9,11 +9,14 @@ import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { relaunchApp } from '../../utils/processUtils.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; interface IdeTrustChangeDialogProps { reason: RestartReason; } +const debugLogger = createDebugLogger('IDE_TRUST_DIALOG'); + export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => { useKeypress( (key) => { @@ -27,7 +30,7 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => { let message = 'Workspace trust has changed.'; if (reason === 'NONE') { // This should not happen, but provides a fallback and a debug log. - console.error( + debugLogger.error( 'IdeTrustChangeDialog rendered with unexpected reason "NONE"', ); } else if (reason === 'CONNECTION_CHANGE') { diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 0ddeee83e..158c15275 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -475,10 +475,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should handle errors during clipboard operations', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should handle errors during clipboard operations gracefully', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue( new Error('Clipboard error'), ); @@ -491,13 +488,9 @@ describe('InputPrompt', () => { stdin.write('\x16'); // Ctrl+V await wait(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error handling clipboard image:', - expect.any(Error), - ); + // Should not throw and should not set buffer text on error expect(mockBuffer.setText).not.toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); unmount(); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 0e3c43806..e0c08808c 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -22,7 +22,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core'; import { parseInputForHighlighting, buildSegmentsForVisualSlice, @@ -39,6 +39,8 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; + +const debugLogger = createDebugLogger('INPUT_PROMPT'); export interface InputPromptProps { buffer: TextBuffer; onSubmit: (value: string) => void; @@ -301,7 +303,7 @@ export const InputPrompt: React.FC = ({ } } } catch (error) { - console.error('Error handling clipboard image:', error); + debugLogger.error('Error handling clipboard image:', error); } }, [buffer, config]); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index db66607dd..a89583a0d 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -7,10 +7,12 @@ import { Box, Static } from 'ink'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { ShowMoreLines } from './ShowMoreLines.js'; +import { Notifications } from './Notifications.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; +import { DebugModeNotification } from './DebugModeNotification.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -35,6 +37,8 @@ export const MainContent = () => { key={uiState.historyRemountKey} items={[ , + , + , ...uiState.history.map((h) => ( { const showInitError = initError && streamingState !== StreamingState.Responding; - if (!showStartupWarnings && !showInitError && !updateInfo) { - return null; - } - return ( <> {updateInfo && } diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx index a93db2b7a..29eb3712a 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx @@ -394,10 +394,6 @@ describe('QwenOAuthProgress', () => { it('should handle QR code generation errors gracefully', async () => { const qrcode = await import('qrcode-terminal'); const mockGenerate = vi.mocked(qrcode.default.generate); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - mockGenerate.mockImplementation(() => { throw new Error('QR Code generation failed'); }); @@ -413,12 +409,6 @@ describe('QwenOAuthProgress', () => { // Should not crash and should not show QR code section since QR generation failed const output = lastFrame(); expect(output).not.toContain('Or scan the QR code below:'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to generate QR code:', - expect.any(Error), - ); - - consoleErrorSpy.mockRestore(); }); it('should not generate QR code when deviceAuth is null', async () => { diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx index d83bfb044..69d42818d 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -12,6 +12,7 @@ import Link from 'ink-link'; import qrcode from 'qrcode-terminal'; import { Colors } from '../colors.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; @@ -29,6 +30,8 @@ interface QwenOAuthProgressProps { authMessage?: string | null; } +const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS'); + /** * Static QR Code Display Component * Renders the QR code and URL once and doesn't re-render unless the URL changes @@ -161,7 +164,7 @@ export function QwenOAuthProgress({ }, ); } catch (error) { - console.error('Failed to generate QR code:', error); + debugLogger.error('Failed to generate QR code:', error); setQrCodeData(null); } }; diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b3bd1c270..8a0decb71 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -29,7 +29,7 @@ import { } from '../../utils/settingsUtils.js'; import { updateOutputLanguageFile } from '../../utils/languageUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { type Config } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js'; @@ -46,6 +46,8 @@ interface SettingsDialogProps { config?: Config; } +const debugLogger = createDebugLogger('SETTINGS_DIALOG'); + const maxItemsToShow = 8; export function SettingsDialog({ @@ -162,7 +164,7 @@ export function SettingsDialog({ {} as Settings, ); - console.log( + debugLogger.debug( `[DEBUG SettingsDialog] Saving ${key} immediately with value:`, newValue, ); @@ -177,7 +179,7 @@ export function SettingsDialog({ if (key === 'general.vimMode' && newValue !== vimEnabled) { // Call toggleVimEnabled to sync the VimModeContext local state toggleVimEnabled().catch((error) => { - console.error('Failed to toggle vim mode:', error); + debugLogger.error('Failed to toggle vim mode:', error); }); } @@ -189,7 +191,7 @@ export function SettingsDialog({ try { config?.setApprovalMode(settings.merged.tools.approvalMode); } catch (error) { - console.error( + debugLogger.error( 'Failed to apply approval mode to current session:', error, ); @@ -663,7 +665,7 @@ export function SettingsDialog({ try { config?.setApprovalMode(settings.merged.tools.approvalMode); } catch (error) { - console.error( + debugLogger.error( 'Failed to apply approval mode to current session:', error, ); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 13a176c62..244258d79 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -9,6 +9,7 @@ import { render, Box, useApp } from 'ink'; import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { SessionPicker } from './SessionPicker.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; interface StandalonePickerScreenProps { sessionService: SessionService; @@ -70,7 +71,7 @@ export async function showResumeSessionPicker( const sessionService = new SessionService(cwd); const hasSession = await sessionService.loadLastSession(); if (!hasSession) { - console.log('No sessions found. Start a new session with `qwen`.'); + writeStdoutLine('No sessions found. Start a new session with `qwen`.'); return undefined; } diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 86b36cd28..0572a0f76 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -10,8 +10,10 @@ import stringWidth from 'string-width'; import { theme } from '../../semantic-colors.js'; import { toCodePoints } from '../../utils/textUtils.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; let enableDebugLog = false; +const debugLogger = createDebugLogger('MAX_SIZED_BOX'); /** * Minimum height for the MaxSizedBox component. @@ -28,7 +30,7 @@ function debugReportError(message: string, element: React.ReactNode) { if (!enableDebugLog) return; if (!React.isValidElement(element)) { - console.error( + debugLogger.error( message, `Invalid element: '${String(element)}' typeof=${typeof element}`, ); @@ -44,10 +46,13 @@ function debugReportError(message: string, element: React.ReactNode) { const lineNumber = elementWithSource._source?.lineNumber; sourceMessage = fileName ? `${fileName}:${lineNumber}` : ''; } catch (error) { - console.error('Error while trying to get file name:', error); + debugLogger.error('Error while trying to get file name:', error); } - console.error(message, `${String(element.type)}. Source: ${sourceMessage}`); + debugLogger.error( + message, + `${String(element.type)}. Source: ${sourceMessage}`, + ); } interface MaxSizedBoxProps { children?: React.ReactNode; diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index f3aaf9c88..baed1c192 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; -import { unescapePath } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, unescapePath } from '@qwen-code/qwen-code-core'; import { toCodePoints, cpLen, @@ -20,6 +20,8 @@ import { import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; +const debugLogger = createDebugLogger('TEXT_BUFFER'); + export type Direction = | 'left' | 'right' @@ -1143,7 +1145,7 @@ function textBufferReducerLogic( break; default: { const exhaustiveCheck: never = dir; - console.error( + debugLogger.error( `Unknown visual movement direction: ${exhaustiveCheck}`, ); return state; @@ -1489,7 +1491,7 @@ function textBufferReducerLogic( default: { const exhaustiveCheck: never = action; - console.error(`Unknown action encountered: ${exhaustiveCheck}`); + debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`); return state; } } @@ -1858,7 +1860,7 @@ export function useTextBuffer({ newText = newText.replace(/\r\n?/g, '\n'); dispatch({ type: 'set_text', payload: newText, pushToUndo: false }); } catch (err) { - console.error('[useTextBuffer] external editor error', err); + debugLogger.error('[useTextBuffer] external editor error', err); } finally { if (wasRaw) setRawMode?.(true); try { diff --git a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx index f9174b663..0cc899b87 100644 --- a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx +++ b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx @@ -11,12 +11,15 @@ import type { SubagentManager, SubagentConfig, } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { theme } from '../../../semantic-colors.js'; import { shouldShowColor, getColorForDisplay } from '../utils.js'; import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { t } from '../../../../i18n/index.js'; +const debugLogger = createDebugLogger('SUBAGENT_CREATION_SUMMARY'); + /** * Step 6: Final confirmation and actions. */ @@ -87,7 +90,7 @@ export function CreationSummary({ } } catch (error) { // Silently handle errors in warning checks - console.warn('Error checking subagent name availability:', error); + debugLogger.warn('Error checking subagent name availability:', error); } // Check length warnings diff --git a/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx index 77cfa47d7..215fe4bb3 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx @@ -6,6 +6,7 @@ import { Box, Text } from 'ink'; import { type SubagentConfig } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { StepNavigationProps } from '../types.js'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -16,6 +17,8 @@ interface AgentDeleteStepProps extends StepNavigationProps { onDelete: (agent: SubagentConfig) => Promise; } +const debugLogger = createDebugLogger('AGENT_DELETE_STEP'); + export function AgentDeleteStep({ selectedAgent, onDelete, @@ -30,7 +33,7 @@ export function AgentDeleteStep({ await onDelete(selectedAgent); // Navigation will be handled by the parent component after successful deletion } catch (error) { - console.error('Failed to delete agent:', error); + debugLogger.error('Failed to delete agent:', error); } } else if (key.name === 'n') { onNavigateBack(); diff --git a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx index f2a5f02e2..79b859707 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx @@ -17,6 +17,7 @@ import { MANAGEMENT_STEPS } from '../types.js'; import { theme } from '../../../semantic-colors.js'; import { getColorForDisplay, shouldShowColor } from '../utils.js'; import type { SubagentConfig, Config } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { t } from '../../../../i18n/index.js'; @@ -25,6 +26,8 @@ interface AgentsManagerDialogProps { config: Config | null; } +const debugLogger = createDebugLogger('AGENTS_MANAGER_DIALOG'); + /** * Main orchestrator component for the agents management dialog. */ @@ -108,7 +111,7 @@ export function AgentsManagerDialog({ setNavigationStack([MANAGEMENT_STEPS.AGENT_SELECTION]); setSelectedAgentIndex(-1); } catch (error) { - console.error('Failed to delete agent:', error); + debugLogger.error('Failed to delete agent:', error); throw error; // Re-throw to let the component handle the error state } }, @@ -253,7 +256,7 @@ export function AgentsManagerDialog({ await loadAgents(); handleNavigateBack(); } catch (error) { - console.error('Failed to save agent changes:', error); + debugLogger.error('Failed to save agent changes:', error); } } }} @@ -282,7 +285,7 @@ export function AgentsManagerDialog({ await loadAgents(); handleNavigateBack(); } catch (error) { - console.error('Failed to save color changes:', error); + debugLogger.error('Failed to save color changes:', error); } } }} diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx index 50b87d8c4..316e66687 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -7,6 +7,9 @@ import { Box, Text } from 'ink'; import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionUpdateState } from '../../state/extensions.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('EXTENSIONS_LIST'); export const ExtensionsList = () => { const { extensionsUpdateState, commandContext } = useUIState(); @@ -47,7 +50,7 @@ export const ExtensionsList = () => { stateColor = 'green'; break; default: - console.error(`Unhandled ExtensionUpdateState ${state}`); + debugLogger.error(`Unhandled ExtensionUpdateState ${state}`); break; } diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1130f8352..c28cd9525 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -1190,20 +1190,7 @@ describe('KeypressContext - Kitty Protocol', () => { }); describe('debug keystroke logging', () => { - let consoleLogSpy: ReturnType; - let consoleWarnSpy: ReturnType; - - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - }); - - it('should not log keystrokes when debugKeystrokeLogging is false', async () => { + it('should handle kitty sequences when debugKeystrokeLogging is false', async () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -1221,138 +1208,20 @@ describe('KeypressContext - Kitty Protocol', () => { result.current.subscribe(keyHandler); }); - // Send a kitty sequence + // Send a kitty sequence - should work without debug logging act(() => { stdin.sendKittySequence('\x1b[27u'); }); - expect(keyHandler).toHaveBeenCalled(); - expect(consoleLogSpy).not.toHaveBeenCalledWith( - expect.stringContaining('[DEBUG] Kitty'), - ); - }); - - it('should log kitty buffer accumulation when debugKeystrokeLogging is true', async () => { - const keyHandler = vi.fn(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send a complete kitty sequence for escape - act(() => { - stdin.sendKittySequence('\x1b[27u'); - }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer accumulating:', - expect.stringContaining('\x1b[27u'), - ); - const parsedCall = consoleLogSpy.mock.calls.find( - (args) => - typeof args[0] === 'string' && - args[0].includes('[DEBUG] Kitty sequence parsed successfully'), - ); - expect(parsedCall).toBeTruthy(); - expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u')); - }); - - it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => { - const keyHandler = vi.fn(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send an invalid long sequence to trigger overflow - const longInvalidSequence = '\x1b[' + 'x'.repeat(100); - act(() => { - stdin.sendKittySequence(longInvalidSequence); - }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer overflow, clearing:', - expect.any(String), - ); - }); - - it('should log kitty buffer clear on Ctrl+C when debugKeystrokeLogging is true', async () => { - const keyHandler = vi.fn(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send incomplete kitty sequence - act(() => { - stdin.pressKey({ - name: undefined, - ctrl: false, - meta: false, - shift: false, - sequence: '\x1b[1', - }); - }); - - // Send Ctrl+C - act(() => { - stdin.pressKey({ - name: 'c', - ctrl: true, - meta: false, - shift: false, - sequence: '\x03', - }); - }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer cleared on Ctrl+C:', - '\x1b[1', - ); - - // Verify Ctrl+C was handled expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'c', - ctrl: true, + name: 'escape', + kittyProtocol: true, }), ); }); - it('should show char codes when debugKeystrokeLogging is true even without debug mode', async () => { + it('should handle kitty sequences when debugKeystrokeLogging is true', async () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -1370,29 +1239,44 @@ describe('KeypressContext - Kitty Protocol', () => { result.current.subscribe(keyHandler); }); - // Send incomplete kitty sequence - const sequence = '\x1b[12'; + // Send a complete kitty sequence for escape - should work with debug logging act(() => { - stdin.pressKey({ - name: undefined, - ctrl: false, - meta: false, - shift: false, - sequence, - }); + stdin.sendKittySequence('\x1b[27u'); }); - // Verify debug logging for accumulation - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer accumulating:', - sequence, + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'escape', + kittyProtocol: true, + }), + ); + }); + + it('should handle kitty buffer overflow without crashing', async () => { + const keyHandler = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + ); - // Verify warning for char codes - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Kitty sequence buffer has char codes:', - [27, 91, 49, 50], - ); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send an invalid long sequence to trigger overflow - should not crash + const longInvalidSequence = '\x1b[' + 'x'.repeat(100); + expect(() => { + act(() => { + stdin.sendKittySequence(longInvalidSequence); + }); + }).not.toThrow(); }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 0f01712cc..bdd8c33d8 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -8,6 +8,7 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { KittySequenceOverflowEvent, logKittySequenceOverflow, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import { useStdin } from 'ink'; import type React from 'react'; @@ -65,6 +66,7 @@ interface KeypressContextValue { const KeypressContext = createContext( undefined, ); +const debugLogger = createDebugLogger('KEYPRESS'); export function useKeypressContext() { const context = useContext(KeypressContext); @@ -486,7 +488,7 @@ export function KeypressProvider({ key.sequence === `${ESC}${KITTY_CTRL_C}` ) { if (kittySequenceBuffer && debugKeystrokeLogging) { - console.log( + debugLogger.debug( '[DEBUG] Kitty buffer cleared on Ctrl+C:', kittySequenceBuffer, ); @@ -520,7 +522,7 @@ export function KeypressProvider({ kittySequenceBuffer += key.sequence; if (debugKeystrokeLogging) { - console.log( + debugLogger.debug( '[DEBUG] Kitty buffer accumulating:', kittySequenceBuffer, ); @@ -538,7 +540,7 @@ export function KeypressProvider({ const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1); if (nextStart > 0) { if (debugKeystrokeLogging) { - console.log( + debugLogger.debug( '[DEBUG] Skipping incomplete/invalid CSI prefix:', kittySequenceBuffer.slice(0, nextStart), ); @@ -554,12 +556,12 @@ export function KeypressProvider({ parsed.length, ); if (kittySequenceBuffer.length > parsed.length) { - console.log( + debugLogger.debug( '[DEBUG] Kitty sequence parsed successfully (prefix):', parsedSequence, ); } else { - console.log( + debugLogger.debug( '[DEBUG] Kitty sequence parsed successfully:', parsedSequence, ); @@ -576,12 +578,12 @@ export function KeypressProvider({ const codes = Array.from(kittySequenceBuffer).map((ch) => ch.charCodeAt(0), ); - console.warn('Kitty sequence buffer has char codes:', codes); + debugLogger.warn('Kitty sequence buffer has char codes:', codes); } if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { if (debugKeystrokeLogging) { - console.log( + debugLogger.debug( '[DEBUG] Kitty buffer overflow, clearing:', kittySequenceBuffer, ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f62819527..6a48d3eca 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -8,7 +8,6 @@ import { createContext, useContext } from 'react'; import type { HistoryItem, ThoughtSummary, - ConsoleMessageItem, ShellConfirmationRequest, ConfirmationRequest, LoopDetectionConfirmationRequest, @@ -81,8 +80,6 @@ export interface UIState { isFolderTrustDialogOpen: boolean; isTrustedFolder: boolean | undefined; constrainHeight: boolean; - showErrorDetails: boolean; - filteredConsoleMessages: ConsoleMessageItem[]; ideContextState: IdeContext | undefined; showToolDescriptions: boolean; ctrlCPressedOnce: boolean; @@ -96,7 +93,6 @@ export interface UIState { // Quota-related state currentModel: string; contextFileNames: string[]; - errorCount: number; availableTerminalHeight: number | undefined; mainAreaWidth: number; staticAreaMaxItemHeight: number; diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index e423a5f9c..f3231479b 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -234,11 +234,8 @@ export async function handleAtCommand({ sawNotFound = true; continue; } else { - console.error( - `Error stating path ${pathName}: ${getErrorMessage(error)}`, - ); onDebugMessage( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, + `Error stating path ${pathName}: ${getErrorMessage(error)}. Path ${pathName} will be skipped.`, ); } } @@ -318,7 +315,6 @@ export async function handleAtCommand({ } const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`; - console.log(message); onDebugMessage(message); } diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index fdc69c2e0..65037942b 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -16,7 +16,11 @@ import type { GeminiClient, ShellExecutionResult, } from '@qwen-code/qwen-code-core'; -import { isBinary, ShellExecutionService } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + isBinary, + ShellExecutionService, +} from '@qwen-code/qwen-code-core'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { SHELL_COMMAND_NAME } from '../constants.js'; @@ -29,6 +33,7 @@ import { themeManager } from '../../ui/themes/theme-manager.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const MAX_OUTPUT_LENGTH = 10000; +const debugLogger = createDebugLogger('SHELL_COMMAND_PROCESSOR'); function addShellCommandToGeminiHistory( geminiClient: GeminiClient, @@ -231,7 +236,7 @@ export const useShellCommandProcessor = ( shellExecutionConfig, ); - console.log(terminalHeight, terminalWidth); + debugLogger.debug(terminalHeight, terminalWidth); executionPid = pid; if (pid) { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ba2b53fc5..59ff06bcf 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -10,6 +10,7 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { type Logger, type Config, + createDebugLogger, GitService, logSlashCommand, makeSlashCommandEvent, @@ -33,12 +34,14 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; +import { clearScreen } from '../../utils/stdioHelpers.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, } from '../state/extensions.js'; type SerializableHistoryItem = Record; +const debugLogger = createDebugLogger('SLASH_COMMAND_PROCESSOR'); function serializeHistoryItemForRecording( item: Omit, @@ -200,7 +203,7 @@ export const useSlashCommandProcessor = ( addItem, clear: () => { clearItems(); - console.clear(); + clearScreen(); refreshStatic(); }, loadHistory, @@ -600,12 +603,10 @@ export const useSlashCommandProcessor = ( }); } } catch (recordError) { - if (config.getDebugMode()) { - console.error( - '[slashCommand] Failed to record slash command:', - recordError, - ); - } + debugLogger.error( + '[slashCommand] Failed to record slash command:', + recordError, + ); } } if (config && resolvedCommandPath[0] && !hasError) { diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.test.ts b/packages/cli/src/ui/hooks/useConsoleMessages.test.ts deleted file mode 100644 index b1d1acd66..000000000 --- a/packages/cli/src/ui/hooks/useConsoleMessages.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { act, renderHook } from '@testing-library/react'; -import { vi } from 'vitest'; -import { useConsoleMessages } from './useConsoleMessages'; -import { useCallback } from 'react'; - -describe('useConsoleMessages', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - }); - - const useTestableConsoleMessages = () => { - const { handleNewMessage, ...rest } = useConsoleMessages(); - const log = useCallback( - (content: string) => handleNewMessage({ type: 'log', content, count: 1 }), - [handleNewMessage], - ); - const error = useCallback( - (content: string) => - handleNewMessage({ type: 'error', content, count: 1 }), - [handleNewMessage], - ); - return { - ...rest, - log, - error, - clearConsoleMessages: rest.clearConsoleMessages, - }; - }; - - it('should initialize with an empty array of console messages', () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - expect(result.current.consoleMessages).toEqual([]); - }); - - it('should add a new message when log is called', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - - act(() => { - result.current.log('Test message'); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(20); - }); - - expect(result.current.consoleMessages).toEqual([ - { type: 'log', content: 'Test message', count: 1 }, - ]); - }); - - it('should batch and count identical consecutive messages', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - - act(() => { - result.current.log('Test message'); - result.current.log('Test message'); - result.current.log('Test message'); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(20); - }); - - expect(result.current.consoleMessages).toEqual([ - { type: 'log', content: 'Test message', count: 3 }, - ]); - }); - - it('should not batch different messages', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - - act(() => { - result.current.log('First message'); - result.current.error('Second message'); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(20); - }); - - expect(result.current.consoleMessages).toEqual([ - { type: 'log', content: 'First message', count: 1 }, - { type: 'error', content: 'Second message', count: 1 }, - ]); - }); - - it('should clear all messages when clearConsoleMessages is called', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - - act(() => { - result.current.log('A message'); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(20); - }); - - expect(result.current.consoleMessages).toHaveLength(1); - - act(() => { - result.current.clearConsoleMessages(); - }); - - expect(result.current.consoleMessages).toHaveLength(0); - }); - - it('should clear the pending timeout when clearConsoleMessages is called', () => { - const { result } = renderHook(() => useTestableConsoleMessages()); - const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); - - act(() => { - result.current.log('A message'); - }); - - act(() => { - result.current.clearConsoleMessages(); - }); - - expect(clearTimeoutSpy).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); - }); - - it('should clean up the timeout on unmount', () => { - const { result, unmount } = renderHook(() => useTestableConsoleMessages()); - const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); - - act(() => { - result.current.log('A message'); - }); - - unmount(); - - expect(clearTimeoutSpy).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); - }); -}); diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.ts b/packages/cli/src/ui/hooks/useConsoleMessages.ts deleted file mode 100644 index af48fc5d7..000000000 --- a/packages/cli/src/ui/hooks/useConsoleMessages.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - useCallback, - useEffect, - useReducer, - useRef, - useTransition, -} from 'react'; -import type { ConsoleMessageItem } from '../types.js'; - -export interface UseConsoleMessagesReturn { - consoleMessages: ConsoleMessageItem[]; - handleNewMessage: (message: ConsoleMessageItem) => void; - clearConsoleMessages: () => void; -} - -type Action = - | { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] } - | { type: 'CLEAR' }; - -function consoleMessagesReducer( - state: ConsoleMessageItem[], - action: Action, -): ConsoleMessageItem[] { - switch (action.type) { - case 'ADD_MESSAGES': { - const newMessages = [...state]; - for (const queuedMessage of action.payload) { - const lastMessage = newMessages[newMessages.length - 1]; - if ( - lastMessage && - lastMessage.type === queuedMessage.type && - lastMessage.content === queuedMessage.content - ) { - // Create a new object for the last message to ensure React detects - // the change, preventing mutation of the existing state object. - newMessages[newMessages.length - 1] = { - ...lastMessage, - count: lastMessage.count + 1, - }; - } else { - newMessages.push({ ...queuedMessage, count: 1 }); - } - } - return newMessages; - } - case 'CLEAR': - return []; - default: - return state; - } -} - -export function useConsoleMessages(): UseConsoleMessagesReturn { - const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []); - const messageQueueRef = useRef([]); - const timeoutRef = useRef(null); - const [, startTransition] = useTransition(); - - const processQueue = useCallback(() => { - if (messageQueueRef.current.length > 0) { - const messagesToProcess = messageQueueRef.current; - messageQueueRef.current = []; - startTransition(() => { - dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess }); - }); - } - timeoutRef.current = null; - }, []); - - const handleNewMessage = useCallback( - (message: ConsoleMessageItem) => { - messageQueueRef.current.push(message); - if (!timeoutRef.current) { - // Batch updates using a timeout. 16ms is a reasonable delay to batch - // rapid-fire messages without noticeable lag. - timeoutRef.current = setTimeout(processQueue, 16); - } - }, - [processQueue], - ); - - const clearConsoleMessages = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - messageQueueRef.current = []; - startTransition(() => { - dispatch({ type: 'CLEAR' }); - }); - }, []); - - // Cleanup on unmount - useEffect( - () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }, - [], - ); - - return { consoleMessages, handleNewMessage, clearConsoleMessages }; -} diff --git a/packages/cli/src/ui/hooks/useFeedbackDialog.ts b/packages/cli/src/ui/hooks/useFeedbackDialog.ts index 281d57ea2..aee0f5a66 100644 --- a/packages/cli/src/ui/hooks/useFeedbackDialog.ts +++ b/packages/cli/src/ui/hooks/useFeedbackDialog.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import * as fs from 'node:fs'; import { type Config, + createDebugLogger, logUserFeedback, UserFeedbackEvent, type UserFeedbackRating, @@ -24,6 +25,7 @@ const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog // Fatigue mechanism constants const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again +const debugLogger = createDebugLogger('FEEDBACK_DIALOG'); /** * Check if the last message in the conversation history is an AI response @@ -43,7 +45,7 @@ const getFeedbackLastShownTimestampFromFile = (): number => { } } catch (error) { if (isNodeError(error) && error.code !== 'ENOENT') { - console.warn( + debugLogger.warn( 'Failed to read feedbackLastShownTimestamp from settings file:', error, ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 7ebfc2200..2d90012cd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1601,9 +1601,6 @@ describe('useGeminiStream', () => { }); it('should handle errors gracefully when auto-approving tool calls', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const mockOnConfirmSuccess = vi.fn().mockResolvedValue(undefined); const mockOnConfirmError = vi .fn() @@ -1673,14 +1670,6 @@ describe('useGeminiStream', () => { // Both confirmation methods should be called expect(mockOnConfirmSuccess).toHaveBeenCalledTimes(1); expect(mockOnConfirmError).toHaveBeenCalledTimes(1); - - // Error should be logged - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to auto-approve tool call call2:', - expect.any(Error), - ); - - consoleSpy.mockRestore(); }); it('should skip tool calls without confirmationDetails', async () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 3d114d1ec..e142d91f0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -19,6 +19,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { GeminiEventType as ServerGeminiEventType, + createDebugLogger, getErrorMessage, isNodeError, MessageSenderType, @@ -65,6 +66,8 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; +const debugLogger = createDebugLogger('GEMINI_STREAM'); + enum StreamProcessingStatus { Completed, UserCancelled, @@ -336,7 +339,7 @@ export const useGeminiStream = ( if (typeof query === 'string') { const trimmedQuery = query.trim(); - onDebugMessage(`User query: '${trimmedQuery}'`); + onDebugMessage(`Received user query (${trimmedQuery.length} chars)`); await logger?.logMessage(MessageSenderType.USER, trimmedQuery); // Handle UI-only commands first @@ -985,7 +988,7 @@ export const useGeminiStream = ( if (processingStatus === StreamProcessingStatus.UserCancelled) { // Restore original model if it was temporarily overridden restoreOriginalModel().catch((error) => { - console.error('Failed to restore original model:', error); + debugLogger.error('Failed to restore original model:', error); }); isSubmittingQueryRef.current = false; return; @@ -1002,12 +1005,12 @@ export const useGeminiStream = ( // Restore original model if it was temporarily overridden restoreOriginalModel().catch((error) => { - console.error('Failed to restore original model:', error); + debugLogger.error('Failed to restore original model:', error); }); } catch (error: unknown) { // Restore original model if it was temporarily overridden restoreOriginalModel().catch((error) => { - console.error('Failed to restore original model:', error); + debugLogger.error('Failed to restore original model:', error); }); if (error instanceof UnauthorizedError) { @@ -1077,7 +1080,7 @@ export const useGeminiStream = ( ToolConfirmationOutcome.ProceedOnce, ); } catch (error) { - console.error( + debugLogger.error( `Failed to auto-approve tool call ${call.request.callId}:`, error, ); diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts index 5404cefc0..49ccef42b 100644 --- a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts @@ -107,8 +107,6 @@ describe('useInputHistoryStore', () => { .mockRejectedValue(new Error('Logger error')), }; - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { result } = renderHook(() => useInputHistoryStore()); await act(async () => { @@ -116,12 +114,6 @@ describe('useInputHistoryStore', () => { }); expect(result.current.inputHistory).toEqual([]); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to initialize input history from logger:', - expect.any(Error), - ); - - consoleSpy.mockRestore(); }); it('should initialize only once', async () => { diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.ts index 86e7cd396..f879e31f0 100644 --- a/packages/cli/src/ui/hooks/useInputHistoryStore.ts +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.ts @@ -5,6 +5,7 @@ */ import { useState, useCallback } from 'react'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; interface Logger { getPreviousUserMessages(): Promise; @@ -16,6 +17,8 @@ export interface UseInputHistoryStoreReturn { initializeFromLogger: (logger: Logger | null) => Promise; } +const debugLogger = createDebugLogger('INPUT_HISTORY_STORE'); + /** * Hook for independently managing input history. * Completely separated from chat history and unaffected by /clear commands. @@ -69,7 +72,10 @@ export function useInputHistoryStore(): UseInputHistoryStoreReturn { setIsInitialized(true); } catch (error) { // Start with empty history even if logger initialization fails - console.warn('Failed to initialize input history from logger:', error); + debugLogger.warn( + 'Failed to initialize input history from logger:', + error, + ); setPastSessionMessages([]); recalculateHistory([], []); setIsInitialized(true); diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 5542718f1..56992f678 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -20,7 +20,10 @@ import type { Status as CoreStatus, EditorType, } from '@qwen-code/qwen-code-core'; -import { CoreToolScheduler } from '@qwen-code/qwen-code-core'; +import { + CoreToolScheduler, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; import { useCallback, useState, useMemo } from 'react'; import type { HistoryItemToolGroup, @@ -28,6 +31,8 @@ import type { } from '../types.js'; import { ToolCallStatus } from '../types.js'; +const debugLogger = createDebugLogger('REACT_TOOL_SCHEDULER'); + export type ScheduleFn = ( request: ToolCallRequestInfo | ToolCallRequestInfo[], signal: AbortSignal, @@ -198,7 +203,7 @@ function mapCoreStatusToDisplayStatus(coreStatus: CoreStatus): ToolCallStatus { return ToolCallStatus.Pending; default: { const exhaustiveCheck: never = coreStatus; - console.warn(`Unknown core status encountered: ${exhaustiveCheck}`); + debugLogger.warn(`Unknown core status encountered: ${exhaustiveCheck}`); return ToolCallStatus.Error; } } diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts index 5e9a7f181..c09aec802 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.ts @@ -5,6 +5,7 @@ */ import { useReducer, useRef, useEffect } from 'react'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { useKeypress } from './useKeypress.js'; export interface SelectionListItem { @@ -22,6 +23,8 @@ export interface UseSelectionListOptions { showNumbers?: boolean; } +const debugLogger = createDebugLogger('SELECTION_LIST'); + export interface UseSelectionListResult { activeIndex: number; setActiveIndex: (index: number) => void; @@ -203,7 +206,7 @@ function selectionListReducer( default: { const exhaustiveCheck: never = action; - console.error(`Unknown selection list action: ${exhaustiveCheck}`); + debugLogger.error(`Unknown selection list action: ${exhaustiveCheck}`); return state; } } diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index 8c4939345..4bfe6c9bb 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -43,6 +43,12 @@ vi.mock('@qwen-code/qwen-code-core', () => { return { isNodeError: (err: unknown): err is NodeJS.ErrnoException => typeof err === 'object' && err !== null && 'code' in err, + createDebugLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), Storage, }; }); diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts index a25965309..69358d890 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.ts @@ -7,9 +7,14 @@ import { useState, useEffect, useCallback } from 'react'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { isNodeError, Storage } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + isNodeError, + Storage, +} from '@qwen-code/qwen-code-core'; const MAX_HISTORY_LENGTH = 100; +const debugLogger = createDebugLogger('SHELL_HISTORY'); export interface UseShellHistoryReturn { history: string[]; @@ -52,7 +57,7 @@ async function readHistoryFile(filePath: string): Promise { return result; } catch (err) { if (isNodeError(err) && err.code === 'ENOENT') return []; - console.error('Error reading history:', err); + debugLogger.error('Error reading history:', err); return []; } } @@ -65,7 +70,7 @@ async function writeHistoryFile( await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, history.join('\n')); } catch (error) { - console.error('Error writing shell history:', error); + debugLogger.error('Error writing shell history:', error); } } diff --git a/packages/cli/src/ui/hooks/useShowMemoryCommand.ts b/packages/cli/src/ui/hooks/useShowMemoryCommand.ts index 5c9682082..971e775fe 100644 --- a/packages/cli/src/ui/hooks/useShowMemoryCommand.ts +++ b/packages/cli/src/ui/hooks/useShowMemoryCommand.ts @@ -8,6 +8,9 @@ import type { Message } from '../types.js'; import { MessageType } from '../types.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('SHOW_MEMORY'); export function createShowMemoryAction( config: Config | null, @@ -24,11 +27,7 @@ export function createShowMemoryAction( return; } - const debugMode = config.getDebugMode(); - - if (debugMode) { - console.log('[DEBUG] Show Memory command invoked.'); - } + debugLogger.debug('[DEBUG] Show Memory command invoked.'); const currentMemory = config.getUserMemory(); const fileCount = config.getGeminiMdFileCount(); @@ -37,12 +36,10 @@ export function createShowMemoryAction( ? contextFileName : [contextFileName]; - if (debugMode) { - console.log( - `[DEBUG] Showing memory. Content from config.getUserMemory() (first 200 chars): ${currentMemory.substring(0, 200)}...`, - ); - console.log(`[DEBUG] Number of context files loaded: ${fileCount}`); - } + debugLogger.debug( + `[DEBUG] Showing memory. Content from config.getUserMemory() (first 200 chars): ${currentMemory.substring(0, 200)}...`, + ); + debugLogger.debug(`[DEBUG] Number of context files loaded: ${fileCount}`); if (fileCount > 0) { const allNamesTheSame = new Set(contextFileNames).size < 2; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 4d5fd7874..0247523ee 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -6,6 +6,7 @@ import { useState, useEffect, useMemo } from 'react'; import { AsyncFzf } from 'fzf'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandKind, @@ -29,13 +30,15 @@ interface FzfCommandCacheEntry { commandMap: Map; } +const debugLogger = createDebugLogger('SLASH_COMPLETION'); + // Utility function to safely handle errors without information disclosure function logErrorSafely(error: unknown, context: string): void { if (error instanceof Error) { // Log full error details securely for debugging - console.error(`[${context}]`, error); + debugLogger.error(`[${context}]`, error); } else { - console.error(`[${context}] Non-error thrown:`, error); + debugLogger.error(`[${context}] Non-error thrown:`, error); } } @@ -190,7 +193,7 @@ function useCommandSuggestions( // Safety check: ensure leafCommand and completion exist if (!leafCommand?.completion) { - console.warn( + debugLogger.warn( 'Attempted argument completion without completion function', ); return; diff --git a/packages/cli/src/ui/hooks/useWelcomeBack.ts b/packages/cli/src/ui/hooks/useWelcomeBack.ts index bbc164016..36ce931be 100644 --- a/packages/cli/src/ui/hooks/useWelcomeBack.ts +++ b/packages/cli/src/ui/hooks/useWelcomeBack.ts @@ -57,9 +57,9 @@ export function useWelcomeBack( } } catch (error) { // Silently ignore errors - welcome back is not critical - console.debug('Welcome back check failed:', error); + config.getDebugLogger().debug('Welcome back check failed:', error); } - }, [settings.ui?.enableWelcomeBack]); + }, [config, settings.ui?.enableWelcomeBack]); // Handle welcome back dialog selection const handleWelcomeBackSelection = useCallback( diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 97b73121d..5a91a35a2 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -5,6 +5,7 @@ */ import { useCallback, useReducer, useEffect } from 'react'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { Key } from './useKeypress.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { useVimMode } from '../contexts/VimModeContext.js'; @@ -16,6 +17,8 @@ const DIGIT_MULTIPLIER = 10; const DEFAULT_COUNT = 1; const DIGIT_1_TO_9 = /^[1-9]$/; +const debugLogger = createDebugLogger('VIM_MODE'); + // Command types const CMD_TYPES = { DELETE_WORD_FORWARD: 'dw', @@ -394,7 +397,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { normalizedKey = normalizeKey(key); } catch (error) { // Handle malformed key inputs gracefully - console.warn('Malformed key input in vim mode:', key, error); + debugLogger.warn('Malformed key input in vim mode:', key, error); return false; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index a0a9b8279..7ca67117c 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -50,7 +50,6 @@ describe('keyMatchers', () => { [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => key.ctrl && (key.name === 'x' || key.sequence === '\x18'), [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', - [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o', [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => @@ -222,11 +221,6 @@ describe('keyMatchers', () => { }, // App level bindings - { - command: Command.SHOW_ERROR_DETAILS, - positive: [createKey('o', { ctrl: true })], - negative: [createKey('o'), createKey('e', { ctrl: true })], - }, { command: Command.TOGGLE_TOOL_DESCRIPTIONS, positive: [createKey('t', { ctrl: true })], diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 5ddba3388..93ad311c6 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -6,7 +6,6 @@ import type React from 'react'; import { Box } from 'ink'; -import { Notifications } from '../components/Notifications.js'; import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; @@ -23,8 +22,6 @@ export const DefaultAppLayout: React.FC = () => { - - {uiState.dialogsVisible ? ( > = { @@ -147,6 +149,8 @@ export const CSS_NAME_TO_HEX_MAP: Readonly> = { yellowgreen: '#9acd32', }; +const debugLogger = createDebugLogger('COLOR_UTILS'); + // Define the set of Ink's named colors for quick lookup export const INK_SUPPORTED_NAMES = new Set([ 'black', @@ -224,7 +228,7 @@ export function resolveColor(colorValue: string): string | undefined { } // 4. Could not resolve - console.warn( + debugLogger.warn( `[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`, ); return undefined; diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 52e03b011..75c6b761d 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -160,22 +160,14 @@ describe('ThemeManager', () => { expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); }); - it('should not load a theme from an untrusted file path and log a message', () => { + it('should not load a theme from an untrusted file path', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme)); - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); const result = themeManager.setActiveTheme('/untrusted/my-theme.json'); expect(result).toBe(false); expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('is outside your home directory'), - ); - - consoleWarnSpy.mockRestore(); }); }); }); diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 7daa6a290..e4d8c3dfa 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -27,6 +27,9 @@ import { ANSI } from './ansi.js'; import { ANSILight } from './ansi-light.js'; import { NoColorTheme } from './no-color.js'; import process from 'node:process'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('THEME_MANAGER'); export interface ThemeDisplay { name: string; @@ -79,7 +82,7 @@ class ThemeManager { const validation = validateCustomTheme(customThemeConfig); if (validation.isValid) { if (validation.warning) { - console.warn(`Theme "${name}": ${validation.warning}`); + debugLogger.warn(`Theme "${name}": ${validation.warning}`); } const themeWithDefaults: CustomTheme = { ...DEFAULT_THEME.colors, @@ -92,10 +95,10 @@ class ThemeManager { const theme = createCustomTheme(themeWithDefaults); this.customThemes.set(name, theme); } catch (error) { - console.warn(`Failed to load custom theme "${name}":`, error); + debugLogger.warn(`Failed to load custom theme "${name}":`, error); } } else { - console.warn(`Invalid custom theme "${name}": ${validation.error}`); + debugLogger.warn(`Invalid custom theme "${name}": ${validation.error}`); } } // If the current active theme is a custom theme, keep it if still valid @@ -260,7 +263,7 @@ class ThemeManager { // 2. Perform security check. const homeDir = path.resolve(os.homedir()); if (!canonicalPath.startsWith(homeDir)) { - console.warn( + debugLogger.warn( `Theme file at "${themePath}" is outside your home directory. ` + `Only load themes from trusted sources.`, ); @@ -273,14 +276,14 @@ class ThemeManager { const validation = validateCustomTheme(customThemeConfig); if (!validation.isValid) { - console.warn( + debugLogger.warn( `Invalid custom theme from file "${themePath}": ${validation.error}`, ); return undefined; } if (validation.warning) { - console.warn(`Theme from "${themePath}": ${validation.warning}`); + debugLogger.warn(`Theme from "${themePath}": ${validation.warning}`); } // 4. Create and cache the theme. @@ -300,7 +303,10 @@ class ThemeManager { if ( !(error instanceof Error && 'code' in error && error.code === 'ENOENT') ) { - console.warn(`Could not load theme from file "${themePath}":`, error); + debugLogger.warn( + `Could not load theme from file "${themePath}":`, + error, + ); } return undefined; } diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 644248fd0..0dabddb22 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -21,9 +21,11 @@ import { MINIMUM_MAX_HEIGHT, } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; // Configure theming and parsing utilities. const lowlight = createLowlight(common); +const debugLogger = createDebugLogger('CODE_COLORIZER'); function renderHastNode( node: Root | Element | HastText | RootContent, @@ -188,7 +190,7 @@ export function colorizeCode( ); } catch (error) { - console.error( + debugLogger.error( `[colorizeCode] Error highlighting code for language "${language}":`, error, ); diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts deleted file mode 100644 index b0dd048e6..000000000 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import util from 'node:util'; -import type { ConsoleMessageItem } from '../types.js'; - -interface ConsolePatcherParams { - onNewMessage?: (message: Omit) => void; - debugMode: boolean; - stderr?: boolean; -} - -export class ConsolePatcher { - private originalConsoleLog = console.log; - private originalConsoleWarn = console.warn; - private originalConsoleError = console.error; - private originalConsoleDebug = console.debug; - private originalConsoleInfo = console.info; - - private params: ConsolePatcherParams; - - constructor(params: ConsolePatcherParams) { - this.params = params; - } - - patch() { - console.log = this.patchConsoleMethod('log', this.originalConsoleLog); - console.warn = this.patchConsoleMethod('warn', this.originalConsoleWarn); - console.error = this.patchConsoleMethod('error', this.originalConsoleError); - console.debug = this.patchConsoleMethod('debug', this.originalConsoleDebug); - console.info = this.patchConsoleMethod('info', this.originalConsoleInfo); - } - - cleanup = () => { - console.log = this.originalConsoleLog; - console.warn = this.originalConsoleWarn; - console.error = this.originalConsoleError; - console.debug = this.originalConsoleDebug; - console.info = this.originalConsoleInfo; - }; - - private formatArgs = (args: unknown[]): string => util.format(...args); - - private patchConsoleMethod = - ( - type: 'log' | 'warn' | 'error' | 'debug' | 'info', - originalMethod: (...args: unknown[]) => void, - ) => - (...args: unknown[]) => { - if (this.params.stderr) { - if (type !== 'debug' || this.params.debugMode) { - this.originalConsoleError(this.formatArgs(args)); - } - } else { - if (this.params.debugMode) { - originalMethod.apply(console, args); - } - - if (type !== 'debug' || this.params.debugMode) { - this.params.onNewMessage?.({ - type, - content: this.formatArgs(args), - count: 1, - }); - } - } - }; -} diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 48efc6e80..ce31078d1 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import stringWidth from 'string-width'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" @@ -17,6 +18,8 @@ const INLINE_CODE_MARKER_LENGTH = 1; // For "`" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" +const debugLogger = createDebugLogger('INLINE_MARKDOWN'); + interface RenderInlineProps { text: string; textColor?: string; @@ -143,7 +146,7 @@ const RenderInlineInternal: React.FC = ({ ); } } catch (e) { - console.error('Error parsing inline markdown part:', fullMatch, e); + debugLogger.error('Error parsing inline markdown part:', fullMatch, e); renderedNode = null; } diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 42910af47..6b79e3dcd 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,10 +6,12 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { execCommand } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, execCommand } from '@qwen-code/qwen-code-core'; const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; +const debugLogger = createDebugLogger('CLIPBOARD_UTILS'); + /** * Checks if the system clipboard contains an image (macOS only for now) * @returns true if clipboard contains an image @@ -115,7 +117,7 @@ export async function saveClipboardImage( // No format worked return null; } catch (error) { - console.error('Error saving clipboard image:', error); + debugLogger.error('Error saving clipboard image:', error); return null; } } diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 89d1045ac..802107f6b 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -6,6 +6,7 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; /** * Common Windows console code pages (CP) used for encoding conversions. @@ -61,6 +62,8 @@ export const isSlashCommand = (query: string): boolean => { return true; }; +const debugLogger = createDebugLogger('COMMAND_UTILS'); + // Copies a string snippet to the clipboard for different platforms export const copyToClipboard = async (text: string): Promise => { const run = (cmd: string, args: string[], options?: SpawnOptions) => @@ -162,7 +165,7 @@ export const getUrlOpenCommand = (): string => { default: // Default to xdg-open, which appears to be supported for the less popular operating systems. openCmd = 'xdg-open'; - console.warn( + debugLogger.warn( `Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`, ); break; diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index d7a7f41e9..2c548eded 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -31,6 +31,9 @@ import { promisify } from 'node:util'; import { isKittyProtocolEnabled } from './kittyProtocolDetector.js'; import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js'; import { t } from '../../i18n/index.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('TERMINAL_SETUP'); const execAsync = promisify(exec); @@ -96,7 +99,7 @@ async function detectTerminal(): Promise { return 'trae'; } catch (error) { // Continue detection even if process check fails - console.debug('Parent process detection failed:', error); + debugLogger.debug('Parent process detection failed:', error); } } @@ -111,7 +114,7 @@ async function backupFile(filePath: string): Promise { await fs.copyFile(filePath, backupPath); } catch (error) { // Log backup errors but continue with operation - console.warn(`Failed to create backup of ${filePath}:`, error); + debugLogger.warn(`Failed to create backup of ${filePath}:`, error); } } diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index cbc538ed5..b13467251 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -8,6 +8,9 @@ import type { UpdateInfo } from 'update-notifier'; import updateNotifier from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('UPDATE_CHECK'); export const FETCH_TIMEOUT_MS = 2000; @@ -95,7 +98,7 @@ export async function checkForUpdates(): Promise { return null; } catch (e) { - console.warn('Failed to check for updates: ' + e); + debugLogger.warn('Failed to check for updates: ' + e); return null; } } diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts index e166444ff..89da6826c 100644 --- a/packages/cli/src/utils/attentionNotification.ts +++ b/packages/cli/src/utils/attentionNotification.ts @@ -5,6 +5,7 @@ */ import process from 'node:process'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; export enum AttentionNotificationReason { ToolApproval = 'tool_approval', @@ -17,6 +18,7 @@ export interface TerminalNotificationOptions { } const TERMINAL_BELL = '\u0007'; +const debugLogger = createDebugLogger('ATTENTION_NOTIFICATION'); /** * Grabs the user's attention by emitting the terminal bell character. @@ -43,7 +45,7 @@ export function notifyTerminalAttention( stream.write(TERMINAL_BELL); return true; } catch (error) { - console.warn('Failed to send terminal bell:', error); + debugLogger.warn('Failed to send terminal bell:', error); return false; } } diff --git a/packages/cli/src/utils/commentJson.test.ts b/packages/cli/src/utils/commentJson.test.ts index fcf2501cd..0cba17cb6 100644 --- a/packages/cli/src/utils/commentJson.test.ts +++ b/packages/cli/src/utils/commentJson.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -158,28 +158,14 @@ describe('commentJson', () => { fs.writeFileSync(testFilePath, corruptedContent, 'utf-8'); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - expect(() => { updateSettingsFilePreservingFormat(testFilePath, { model: 'gemini-2.5-flash', }); }).not.toThrow(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Error parsing settings file:', - expect.any(Error), - ); - expect(consoleSpy).toHaveBeenCalledWith( - 'Settings file may be corrupted. Please check the JSON syntax.', - ); - const unchangedContent = fs.readFileSync(testFilePath, 'utf-8'); expect(unchangedContent).toBe(corruptedContent); - - consoleSpy.mockRestore(); }); }); }); diff --git a/packages/cli/src/utils/commentJson.ts b/packages/cli/src/utils/commentJson.ts index bf325d9af..58b83e948 100644 --- a/packages/cli/src/utils/commentJson.ts +++ b/packages/cli/src/utils/commentJson.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import { parse, stringify } from 'comment-json'; +import { writeStderrLine } from './stdioHelpers.js'; /** * Updates a JSON file while preserving comments and formatting. @@ -25,8 +26,9 @@ export function updateSettingsFilePreservingFormat( try { parsed = parse(originalContent) as Record; } catch (error) { - console.error('Error parsing settings file:', error); - console.error( + writeStderrLine('Error parsing settings file.'); + writeStderrLine(error instanceof Error ? error.message : String(error)); + writeStderrLine( 'Settings file may be corrupted. Please check the JSON syntax.', ); return; diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index 9a6ee1c63..1dfa0deda 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -19,6 +19,14 @@ import { handleMaxTurnsExceededError, } from './errors.js'; +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const debugLoggerSpy = vi.hoisted(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + // Mock the core modules vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = @@ -26,6 +34,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return { ...original, + createDebugLogger: () => ({ + debug: debugLoggerSpy.debug, + info: debugLoggerSpy.info, + warn: debugLoggerSpy.warn, + error: debugLoggerSpy.error, + }), parseAndFormatApiError: vi.fn((error: unknown) => { if (error instanceof Error) { return `API Error: ${error.message}`; @@ -66,18 +80,25 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); +vi.mock('./stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: vi.fn(), + clearScreen: vi.fn(), +})); + describe('errors', () => { let mockConfig: Config; let processExitSpy: MockInstance; let processStderrWriteSpy: MockInstance; - let consoleErrorSpy: MockInstance; beforeEach(() => { // Reset mocks vi.clearAllMocks(); - - // Mock console.error - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); + debugLoggerSpy.debug.mockClear(); + debugLoggerSpy.info.mockClear(); + debugLoggerSpy.warn.mockClear(); + debugLoggerSpy.error.mockClear(); // Mock process.stderr.write processStderrWriteSpy = vi @@ -99,7 +120,6 @@ describe('errors', () => { }); afterEach(() => { - consoleErrorSpy.mockRestore(); processStderrWriteSpy.mockRestore(); processExitSpy.mockRestore(); }); @@ -163,7 +183,9 @@ describe('errors', () => { handleError(testError, mockConfig); }).toThrow(testError); - expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: Test error'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'API Error: Test error', + ); }); it('should handle non-Error objects', () => { @@ -173,7 +195,9 @@ describe('errors', () => { handleError(testError, mockConfig); }).toThrow(testError); - expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: String error'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'API Error: String error', + ); }); }); @@ -191,7 +215,7 @@ describe('errors', () => { handleError(testError, mockConfig); }).toThrow('process.exit called with code: 1'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { @@ -213,7 +237,7 @@ describe('errors', () => { handleError(testError, mockConfig, 42); }).toThrow('process.exit called with code: 42'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { @@ -235,7 +259,7 @@ describe('errors', () => { handleError(fatalError, mockConfig); }).toThrow('process.exit called with code: 42'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { @@ -271,7 +295,7 @@ describe('errors', () => { handleError(errorWithStatus, mockConfig); }).toThrow('process.exit called with code: 1'); // string codes become 1 - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { @@ -307,7 +331,7 @@ describe('errors', () => { it('should log error message to stderr and not exit', () => { handleToolError(toolName, toolError, mockConfig); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Tool failed', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -322,7 +346,7 @@ describe('errors', () => { 'Custom display message', ); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Custom display message', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -340,7 +364,7 @@ describe('errors', () => { handleToolError(toolName, toolError, mockConfig); // In JSON mode, should not exit (just log to stderr when debug mode is on) - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Tool failed', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -350,7 +374,7 @@ describe('errors', () => { handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR'); // In JSON mode, should not exit (just log to stderr when debug mode is on) - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Tool failed', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -360,7 +384,7 @@ describe('errors', () => { handleToolError(toolName, toolError, mockConfig, 500); // In JSON mode, should not exit (just log to stderr when debug mode is on) - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Tool failed', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -376,7 +400,7 @@ describe('errors', () => { ); // In JSON mode, should not exit (just log to stderr when debug mode is on) - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Display message', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -394,7 +418,7 @@ describe('errors', () => { handleToolError(toolName, toolError, mockConfig); // Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on) - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(debugLoggerSpy.error).toHaveBeenCalledWith( 'Error executing tool test-tool: Tool failed', ); expect(processExitSpy).not.toHaveBeenCalled(); @@ -407,36 +431,42 @@ describe('errors', () => { (mockConfig.getDebugMode as Mock).mockReturnValue(false); }); - it('should not log and not exit in text mode', () => { + it('should log error and not exit in text mode', () => { ( mockConfig.getOutputFormat as ReturnType ).mockReturnValue(OutputFormat.TEXT); handleToolError(toolName, toolError, mockConfig); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should not log and not exit in JSON mode', () => { + it('should log error and not exit in JSON mode', () => { ( mockConfig.getOutputFormat as ReturnType ).mockReturnValue(OutputFormat.JSON); handleToolError(toolName, toolError, mockConfig); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should not log and not exit in STREAM_JSON mode', () => { + it('should log error and not exit in STREAM_JSON mode', () => { ( mockConfig.getOutputFormat as ReturnType ).mockReturnValue(OutputFormat.STREAM_JSON); handleToolError(toolName, toolError, mockConfig); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); expect(processExitSpy).not.toHaveBeenCalled(); }); }); @@ -565,7 +595,9 @@ describe('errors', () => { handleCancellationError(mockConfig); }).toThrow('process.exit called with code: 130'); - expect(consoleErrorSpy).toHaveBeenCalledWith('Operation cancelled.'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Operation cancelled.', + ); }); }); @@ -581,7 +613,7 @@ describe('errors', () => { handleCancellationError(mockConfig); }).toThrow('process.exit called with code: 130'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { @@ -611,7 +643,7 @@ describe('errors', () => { handleMaxTurnsExceededError(mockConfig); }).toThrow('process.exit called with code: 53'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', ); }); @@ -629,7 +661,7 @@ describe('errors', () => { handleMaxTurnsExceededError(mockConfig); }).toThrow('process.exit called with code: 53'); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( JSON.stringify( { error: { diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 68459c670..598f535b7 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -12,7 +12,11 @@ import { FatalTurnLimitedError, FatalCancellationError, ToolErrorType, + createDebugLogger, } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from './stdioHelpers.js'; + +const debugLogger = createDebugLogger('CLI_ERRORS'); export function getErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -101,10 +105,10 @@ export function handleError( errorCode, ); - console.error(formattedError); + writeStderrLine(formattedError); process.exit(getNumericExitCode(errorCode)); } else { - console.error(errorMessage); + writeStderrLine(errorMessage); throw error; } } @@ -143,12 +147,9 @@ export function handleToolError( process.stderr.write(warningMessage); } - // Always log detailed error in debug mode - if (config.getDebugMode()) { - console.error( - `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, - ); - } + debugLogger.error( + `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, + ); } /** @@ -164,10 +165,10 @@ export function handleCancellationError(config: Config): never { cancellationError.exitCode, ); - console.error(formattedError); + writeStderrLine(formattedError); process.exit(cancellationError.exitCode); } else { - console.error(cancellationError.message); + writeStderrLine(cancellationError.message); process.exit(cancellationError.exitCode); } } @@ -187,10 +188,10 @@ export function handleMaxTurnsExceededError(config: Config): never { maxTurnsError.exitCode, ); - console.error(formattedError); + writeStderrLine(formattedError); process.exit(maxTurnsError.exitCode); } else { - console.error(maxTurnsError.message); + writeStderrLine(maxTurnsError.message); process.exit(maxTurnsError.exitCode); } } diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index fcef4bf3e..58b0bfde1 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -6,6 +6,9 @@ import { execSync } from 'node:child_process'; import { ProxyAgent } from 'undici'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('GIT'); /** * Checks if a directory is within a git repository hosted on GitHub. @@ -24,7 +27,7 @@ export const isGitHubRepository = (): boolean => { return pattern.test(remotes); } catch (_error) { // If any filesystem error occurs, assume not a git repo - console.debug(`Failed to get git remote:`, _error); + debugLogger.debug(`Failed to get git remote:`, _error); return false; } }; @@ -83,7 +86,7 @@ export const getLatestGitHubRelease = async ( } return releaseTag; } catch (_error) { - console.debug( + debugLogger.debug( `Failed to determine latest qwen-code-action release:`, _error, ); diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 6eeb45032..1da119db7 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -11,9 +11,14 @@ import * as path from 'node:path'; import * as childProcess from 'node:child_process'; import { isGitRepository } from '@qwen-code/qwen-code-core'; -vi.mock('@qwen-code/qwen-code-core', () => ({ - isGitRepository: vi.fn(), -})); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isGitRepository: vi.fn(), + }; +}); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); @@ -58,8 +63,7 @@ describe('getInstallationInfo', () => { expect(info.packageManager).toBe(PackageManager.UNKNOWN); }); - it('should return UNKNOWN and log error if realpathSync fails', () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('should return UNKNOWN if realpathSync fails', () => { process.argv[1] = '/path/to/cli'; const error = new Error('realpath failed'); mockedRealPathSync.mockImplementation(() => { @@ -69,8 +73,6 @@ describe('getInstallationInfo', () => { const info = getInstallationInfo(projectRoot, false); expect(info.packageManager).toBe(PackageManager.UNKNOWN); - expect(consoleSpy).toHaveBeenCalledWith(error); - consoleSpy.mockRestore(); }); it('should detect running from a local git clone', () => { diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index d910c158d..6eb39b054 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { isGitRepository } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, isGitRepository } from '@qwen-code/qwen-code-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as childProcess from 'node:child_process'; @@ -21,6 +21,8 @@ export enum PackageManager { UNKNOWN = 'unknown', } +const debugLogger = createDebugLogger('INSTALLATION_INFO'); + export interface InstallationInfo { packageManager: PackageManager; isGlobal: boolean; @@ -170,7 +172,7 @@ export function getInstallationInfo( : `Please run ${updateCommand} to update`, }; } catch (error) { - console.log(error); + debugLogger.error('Failed to detect installation info:', error); return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; } } diff --git a/packages/cli/src/utils/modelConfigUtils.test.ts b/packages/cli/src/utils/modelConfigUtils.test.ts index 28812e8d4..0d39ed06e 100644 --- a/packages/cli/src/utils/modelConfigUtils.test.ts +++ b/packages/cli/src/utils/modelConfigUtils.test.ts @@ -16,6 +16,8 @@ import { } from './modelConfigUtils.js'; import type { Settings } from '../config/settings.js'; +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); @@ -25,6 +27,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); +vi.mock('./stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: vi.fn(), + clearScreen: vi.fn(), +})); + describe('modelConfigUtils', () => { describe('getAuthTypeFromEnv', () => { const originalEnv = process.env; @@ -122,17 +130,15 @@ describe('modelConfigUtils', () => { describe('resolveCliGenerationConfig', () => { const originalEnv = process.env; - const originalConsoleWarn = console.warn; beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; - console.warn = vi.fn(); + mockWriteStderrLine.mockClear(); }); afterEach(() => { process.env = originalEnv; - console.warn = originalConsoleWarn; vi.clearAllMocks(); }); @@ -521,8 +527,8 @@ describe('modelConfigUtils', () => { selectedAuthType, }); - expect(console.warn).toHaveBeenCalledWith('Warning 1'); - expect(console.warn).toHaveBeenCalledWith('Warning 2'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 1'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 2'); }); it('should use custom env when provided', () => { diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index e9cd050c3..948e6b253 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -13,6 +13,7 @@ import { type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; +import { writeStderrLine } from './stdioHelpers.js'; export interface CliGenerationConfigInputs { argv: { @@ -131,7 +132,7 @@ export function resolveCliGenerationConfig( // Log warnings if any for (const warning of resolved.warnings) { - console.warn(warning); + writeStderrLine(warning); } // Resolve OpenAI logging config (CLI-specific, not part of core resolver) diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 89999e2e8..b5565bb52 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -301,11 +301,8 @@ describe('extractUsageFromGeminiClient', () => { throw new Error('Test error'); }), }; - const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); const result = extractUsageFromGeminiClient(client); expect(result).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); }); it('should skip responses without usageMetadata', () => { diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index 6f11bd373..4e2317b2e 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -16,6 +16,7 @@ import type { import { OutputFormat, ToolErrorType, + createDebugLogger, getMCPServerStatus, } from '@qwen-code/qwen-code-core'; import type { Part, PartListUnion } from '@google/genai'; @@ -29,6 +30,8 @@ import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOu import { computeSessionStats } from '../ui/utils/computeStats.js'; import { getAvailableCommands } from '../nonInteractiveCliCommands.js'; +const debugLogger = createDebugLogger('NON_INTERACTIVE'); + /** * Normalizes various part list formats into a consistent Part[] array. * @@ -144,7 +147,7 @@ export function extractUsageFromGeminiClient( } } } catch (error) { - console.debug('Failed to extract usage metadata:', error); + debugLogger.debug('Failed to extract usage metadata:', error); } return undefined; @@ -208,12 +211,10 @@ async function loadSlashCommandNames( // Extract command names and sort return commands.map((cmd) => cmd.name).sort(); } catch (error) { - if (config.getDebugMode()) { - console.error( - '[buildSystemMessage] Failed to load slash commands:', - error, - ); - } + debugLogger.error( + '[buildSystemMessage] Failed to load slash commands:', + error, + ); return []; } finally { controller.abort(); @@ -269,9 +270,7 @@ export async function buildSystemMessage( const subagents = await subagentManager.listSubagents(); agentNames = subagents.map((subagent) => subagent.name); } catch (error) { - if (config.getDebugMode()) { - console.error('[buildSystemMessage] Failed to load subagents:', error); - } + debugLogger.error('[buildSystemMessage] Failed to load subagents:', error); } const systemMessage: CLISystemMessage = { diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts index 3ccdaee75..b95dddc74 100644 --- a/packages/cli/src/utils/readStdin.ts +++ b/packages/cli/src/utils/readStdin.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeStderrLine } from './stdioHelpers.js'; + export async function readStdin(): Promise { const MAX_STDIN_SIZE = 8 * 1024 * 1024; // 8MB return new Promise((resolve, reject) => { @@ -30,7 +32,7 @@ export async function readStdin(): Promise { if (totalSize + chunk.length > MAX_STDIN_SIZE) { const remainingSize = MAX_STDIN_SIZE - totalSize; data += chunk.slice(0, remainingSize); - console.warn( + writeStderrLine( `Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`, ); process.stdin.destroy(); // Stop reading further diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 0ed8c4856..1d137bced 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -33,14 +33,12 @@ import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js'; describe('relaunchOnExitCode', () => { let processExitSpy: MockInstance; - let consoleErrorSpy: MockInstance; let stdinResumeSpy: MockInstance; beforeEach(() => { processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('PROCESS_EXIT_CALLED'); }); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); stdinResumeSpy = vi .spyOn(process.stdin, 'resume') .mockImplementation(() => process.stdin); @@ -49,7 +47,6 @@ describe('relaunchOnExitCode', () => { afterEach(() => { processExitSpy.mockRestore(); - consoleErrorSpy.mockRestore(); stdinResumeSpy.mockRestore(); }); @@ -90,10 +87,6 @@ describe('relaunchOnExitCode', () => { ); expect(runner).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Fatal error: Failed to relaunch the CLI process.', - error, - ); expect(stdinResumeSpy).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); }); @@ -101,7 +94,6 @@ describe('relaunchOnExitCode', () => { describe('relaunchAppInChildProcess', () => { let processExitSpy: MockInstance; - let consoleErrorSpy: MockInstance; let stdinPauseSpy: MockInstance; let stdinResumeSpy: MockInstance; @@ -124,7 +116,6 @@ describe('relaunchAppInChildProcess', () => { processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('PROCESS_EXIT_CALLED'); }); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); stdinPauseSpy = vi .spyOn(process.stdin, 'pause') .mockImplementation(() => process.stdin); @@ -140,7 +131,6 @@ describe('relaunchAppInChildProcess', () => { process.execPath = originalExecPath; processExitSpy.mockRestore(); - consoleErrorSpy.mockRestore(); stdinPauseSpy.mockRestore(); stdinResumeSpy.mockRestore(); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 80d243c70..f80a6a2c3 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -6,6 +6,7 @@ import { spawn } from 'node:child_process'; import { RELAUNCH_EXIT_CODE } from './processUtils.js'; +import { writeStderrLine } from './stdioHelpers.js'; export async function relaunchOnExitCode(runner: () => Promise) { while (true) { @@ -17,7 +18,8 @@ export async function relaunchOnExitCode(runner: () => Promise) { } } catch (error) { process.stdin.resume(); - console.error('Fatal error: Failed to relaunch the CLI process.', error); + writeStderrLine('Fatal error: Failed to relaunch the CLI process.'); + writeStderrLine(error instanceof Error ? error.message : String(error)); process.exit(1); } } diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 71f5c47d8..25b07185e 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -17,8 +17,8 @@ import { import { promisify } from 'node:util'; import type { Config, SandboxConfig } from '@qwen-code/qwen-code-core'; import { FatalSandboxError } from '@qwen-code/qwen-code-core'; -import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; import { randomBytes } from 'node:crypto'; +import { writeStdoutLine, writeStderrLine } from './stdioHelpers.js'; const execAsync = promisify(exec); @@ -81,7 +81,7 @@ async function shouldUseCurrentUserInSandbox(): Promise { ); if (debugEnv) { // Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs). - console.error( + writeStderrLine( 'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.', ); } @@ -178,625 +178,124 @@ export async function start_sandbox( cliConfig?: Config, cliArgs: string[] = [], ): Promise { - const patcher = new ConsolePatcher({ - debugMode: cliConfig?.getDebugMode() || !!process.env['DEBUG'], - stderr: true, - }); - patcher.patch(); - - try { - if (config.command === 'sandbox-exec') { - // disallow BUILD_SANDBOX - if (process.env['BUILD_SANDBOX']) { - throw new FatalSandboxError( - 'Cannot BUILD_SANDBOX when using macOS Seatbelt', - ); - } - - const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open'); - let profileFile = fileURLToPath( - new URL(`sandbox-macos-${profile}.sb`, import.meta.url), - ); - // if profile name is not recognized, then look for file under project settings directory - if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join( - SETTINGS_DIRECTORY_NAME, - `sandbox-macos-${profile}.sb`, - ); - } - if (!fs.existsSync(profileFile)) { - throw new FatalSandboxError( - `Missing macos seatbelt profile file '${profileFile}'`, - ); - } - // Log on STDERR so it doesn't clutter the output on STDOUT - console.error(`using macos seatbelt (profile: ${profile}) ...`); - // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS - const nodeOptions = [ - ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), - ...nodeArgs, - ].join(' '); - - const args = [ - '-D', - `TARGET_DIR=${fs.realpathSync(process.cwd())}`, - '-D', - `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, - '-D', - `HOME_DIR=${fs.realpathSync(os.homedir())}`, - '-D', - `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, - ]; - - // Add included directories from the workspace context - // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them - const MAX_INCLUDE_DIRS = 5; - const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); - const includedDirs: string[] = []; - - if (cliConfig) { - const workspaceContext = cliConfig.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); - - // Filter out TARGET_DIR - for (const dir of directories) { - const realDir = fs.realpathSync(dir); - if (realDir !== targetDir) { - includedDirs.push(realDir); - } - } - } - - for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { - let dirPath = '/dev/null'; // Default to a safe path that won't cause issues - - if (i < includedDirs.length) { - dirPath = includedDirs[i]; - } - - args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); - } - - const finalArgv = cliArgs; - - args.push( - '-f', - profileFile, - 'sh', - '-c', - [ - `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${nodeOptions}"`, - ...finalArgv.map((arg) => quote([arg])), - ].join(' '), - ); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - const sandboxEnv = { ...process.env }; - if (proxyCommand) { - const proxy = - process.env['HTTPS_PROXY'] || - process.env['https_proxy'] || - process.env['HTTP_PROXY'] || - process.env['http_proxy'] || - 'http://localhost:8877'; - sandboxEnv['HTTPS_PROXY'] = proxy; - sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl - sandboxEnv['HTTP_PROXY'] = proxy; - sandboxEnv['http_proxy'] = proxy; - const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; - if (noProxy) { - sandboxEnv['NO_PROXY'] = noProxy; - sandboxEnv['no_proxy'] = noProxy; - } - // Note: CodeQL flags this as js/shell-command-injection-from-environment. - // This is intentional - CLI tool executes user-provided proxy commands. - proxyProcess = spawn('bash', ['-c', proxyCommand], { - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - }); - // install handlers to stop proxy on exit/signal - const stopProxy = () => { - console.log('stopping proxy ...'); - if (proxyProcess?.pid) { - process.kill(-proxyProcess.pid, 'SIGTERM'); - } - }; - process.on('exit', stopProxy); - process.on('SIGINT', stopProxy); - process.on('SIGTERM', stopProxy); - - // commented out as it disrupts ink rendering - // proxyProcess.stdout?.on('data', (data) => { - // console.info(data.toString()); - // }); - proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString()); - }); - proxyProcess.on('close', (code, signal) => { - if (sandboxProcess?.pid) { - process.kill(-sandboxProcess.pid, 'SIGTERM'); - } - throw new FatalSandboxError( - `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, - ); - }); - console.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); - } - // spawn child and let it inherit stdio - process.stdin.pause(); - sandboxProcess = spawn(config.command, args, { - stdio: 'inherit', - }); - return new Promise((resolve, reject) => { - sandboxProcess?.on('error', reject); - sandboxProcess?.on('close', (code) => { - process.stdin.resume(); - resolve(code ?? 1); - }); - }); - } - - console.error(`hopping into sandbox (command: ${config.command}) ...`); - - // determine full path for gemini-cli to distinguish linked vs installed setting - const gcPath = fs.realpathSync(process.argv[1]); - - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); - - const image = config.image; - const workdir = path.resolve(process.cwd()); - const containerWorkdir = getContainerPath(workdir); - - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo - // - // note this can only be done with binary linked from gemini-cli repo + if (config.command === 'sandbox-exec') { + // disallow BUILD_SANDBOX if (process.env['BUILD_SANDBOX']) { - if (!gcPath.includes('qwen-code/packages/')) { - throw new FatalSandboxError( - 'Cannot build sandbox using installed Qwen Code binary; ' + - 'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.', - ); - } else { - console.error('building sandbox ...'); - const gcRoot = gcPath.split('/packages/')[0]; - // if project folder has sandbox.Dockerfile under project settings folder, use that - let buildArgs = ''; - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - if (isCustomProjectSandbox) { - console.error(`using ${projectSandboxDockerfile} for sandbox`); - buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; - } - execSync( - `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, - { - stdio: 'inherit', - env: { - ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) - }, - }, - ); - } - } - - // stop if image is missing - if (!(await ensureSandboxImageIsPresent(config.command, image))) { - const remedy = - image === LOCAL_DEV_SANDBOX_IMAGE_NAME - ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; throw new FatalSandboxError( - `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, + 'Cannot BUILD_SANDBOX when using macOS Seatbelt', ); } - // use interactive mode and auto-remove container on exit - // run init binary inside container to forward signals & reap zombies - const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; - - // add custom flags from SANDBOX_FLAGS - if (process.env['SANDBOX_FLAGS']) { - const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( - (f): f is string => typeof f === 'string', - ); - args.push(...flags); - } - - // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container - if (process.stdin.isTTY) { - args.push('-t'); - } - - // allow access to host.docker.internal - args.push('--add-host', 'host.docker.internal:host-gateway'); - - // mount current directory as working directory in sandbox (set via --workdir) - args.push('--volume', `${workdir}:${containerWorkdir}`); - - // mount user settings directory inside container, after creating if missing - // note user/home changes inside sandbox and we mount at BOTH paths for consistency - const userSettingsDirOnHost = USER_SETTINGS_DIR; - const userSettingsDirInSandbox = getContainerPath( - `/home/node/${SETTINGS_DIRECTORY_NAME}`, + const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open'); + let profileFile = fileURLToPath( + new URL(`sandbox-macos-${profile}.sb`, import.meta.url), ); - if (!fs.existsSync(userSettingsDirOnHost)) { - fs.mkdirSync(userSettingsDirOnHost); - } - args.push( - '--volume', - `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`, - ); - if (userSettingsDirInSandbox !== userSettingsDirOnHost) { - args.push( - '--volume', - `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, + // if profile name is not recognized, then look for file under project settings directory + if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { + profileFile = path.join( + SETTINGS_DIRECTORY_NAME, + `sandbox-macos-${profile}.sb`, ); } - - // mount os.tmpdir() as os.tmpdir() inside container - args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); - - // mount gcloud config directory if it exists - const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); - if (fs.existsSync(gcloudConfigDir)) { - args.push( - '--volume', - `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, + if (!fs.existsSync(profileFile)) { + throw new FatalSandboxError( + `Missing macos seatbelt profile file '${profileFile}'`, ); } + // Log on STDERR so it doesn't clutter the output on STDOUT + writeStderrLine(`using macos seatbelt (profile: ${profile}) ...`); + // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS + const nodeOptions = [ + ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), + ...nodeArgs, + ].join(' '); - // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set - if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) { - const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS']; - if (fs.existsSync(adcFile)) { - args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); - args.push( - '--env', - `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, - ); - } - } + const args = [ + '-D', + `TARGET_DIR=${fs.realpathSync(process.cwd())}`, + '-D', + `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, + '-D', + `HOME_DIR=${fs.realpathSync(os.homedir())}`, + '-D', + `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, + ]; - // mount paths listed in SANDBOX_MOUNTS - if (process.env['SANDBOX_MOUNTS']) { - for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) { - if (mount.trim()) { - // parse mount as from:to:opts - let [from, to, opts] = mount.trim().split(':'); - to = to || from; // default to mount at same path inside container - opts = opts || 'ro'; // default to read-only - mount = `${from}:${to}:${opts}`; - // check that from path is absolute - if (!path.isAbsolute(from)) { - throw new FatalSandboxError( - `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`, - ); - } - // check that from path exists on host - if (!fs.existsSync(from)) { - throw new FatalSandboxError( - `Missing mount path '${from}' listed in SANDBOX_MOUNTS`, - ); - } - console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); - args.push('--volume', mount); + // Add included directories from the workspace context + // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them + const MAX_INCLUDE_DIRS = 5; + const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); + const includedDirs: string[] = []; + + if (cliConfig) { + const workspaceContext = cliConfig.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Filter out TARGET_DIR + for (const dir of directories) { + const realDir = fs.realpathSync(dir); + if (realDir !== targetDir) { + includedDirs.push(realDir); } } } - // expose env-specified ports on the sandbox - ports().forEach((p) => args.push('--publish', `${p}:${p}`)); + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { + let dirPath = '/dev/null'; // Default to a safe path that won't cause issues - // if DEBUG is set, expose debugging port - if (process.env['DEBUG']) { - const debugPort = process.env['DEBUG_PORT'] || '9229'; - args.push(`--publish`, `${debugPort}:${debugPort}`); + if (i < includedDirs.length) { + dirPath = includedDirs[i]; + } + + args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); } - // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME - // copy as both upper-case and lower-case as is required by some utilities - // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + const finalArgv = cliArgs; + args.push( + '-f', + profileFile, + 'sh', + '-c', + [ + `SANDBOX=sandbox-exec`, + `NODE_OPTIONS="${nodeOptions}"`, + ...finalArgv.map((arg) => quote([arg])), + ].join(' '), + ); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + const sandboxEnv = { ...process.env }; if (proxyCommand) { - let proxy = + const proxy = process.env['HTTPS_PROXY'] || process.env['https_proxy'] || process.env['HTTP_PROXY'] || process.env['http_proxy'] || 'http://localhost:8877'; - proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); - if (proxy) { - args.push('--env', `HTTPS_PROXY=${proxy}`); - args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl - args.push('--env', `HTTP_PROXY=${proxy}`); - args.push('--env', `http_proxy=${proxy}`); - } + sandboxEnv['HTTPS_PROXY'] = proxy; + sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl + sandboxEnv['HTTP_PROXY'] = proxy; + sandboxEnv['http_proxy'] = proxy; const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; if (noProxy) { - args.push('--env', `NO_PROXY=${noProxy}`); - args.push('--env', `no_proxy=${noProxy}`); + sandboxEnv['NO_PROXY'] = noProxy; + sandboxEnv['no_proxy'] = noProxy; } - - // if using proxy, switch to internal networking through proxy - if (proxy) { - execSync( - `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, - ); - args.push('--network', SANDBOX_NETWORK_NAME); - // if proxy command is set, create a separate network w/ host access (i.e. non-internal) - // we will run proxy in its own container connected to both host network and internal network - // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation - if (proxyCommand) { - execSync( - `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, - ); - } - } - } - - // name container after image, plus random suffix to avoid conflicts - const imageName = parseImageName(image); - const isIntegrationTest = - process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; - let containerName; - if (isIntegrationTest) { - containerName = `gemini-cli-integration-test-${randomBytes(4).toString( - 'hex', - )}`; - console.log(`ContainerName: ${containerName}`); - } else { - let index = 0; - const containerNameCheck = execSync( - `${config.command} ps -a --format "{{.Names}}"`, - ) - .toString() - .trim(); - while (containerNameCheck.includes(`${imageName}-${index}`)) { - index++; - } - containerName = `${imageName}-${index}`; - console.log(`ContainerName (regular): ${containerName}`); - } - args.push('--name', containerName, '--hostname', containerName); - - // copy GEMINI_CLI_TEST_VAR for integration tests - if (process.env['GEMINI_CLI_TEST_VAR']) { - args.push( - '--env', - `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`, - ); - } - - // copy GEMINI_API_KEY(s) - if (process.env['GEMINI_API_KEY']) { - args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`); - } - if (process.env['GOOGLE_API_KEY']) { - args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`); - } - - // copy OPENAI_API_KEY and related env vars for Qwen - if (process.env['OPENAI_API_KEY']) { - args.push('--env', `OPENAI_API_KEY=${process.env['OPENAI_API_KEY']}`); - } - // copy TAVILY_API_KEY for web search tool - if (process.env['TAVILY_API_KEY']) { - args.push('--env', `TAVILY_API_KEY=${process.env['TAVILY_API_KEY']}`); - } - if (process.env['OPENAI_BASE_URL']) { - args.push('--env', `OPENAI_BASE_URL=${process.env['OPENAI_BASE_URL']}`); - } - if (process.env['OPENAI_MODEL']) { - args.push('--env', `OPENAI_MODEL=${process.env['OPENAI_MODEL']}`); - } - - // copy GOOGLE_GENAI_USE_VERTEXAI - if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) { - args.push( - '--env', - `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`, - ); - } - - // copy GOOGLE_GENAI_USE_GCA - if (process.env['GOOGLE_GENAI_USE_GCA']) { - args.push( - '--env', - `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`, - ); - } - - // copy GOOGLE_CLOUD_PROJECT - if (process.env['GOOGLE_CLOUD_PROJECT']) { - args.push( - '--env', - `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`, - ); - } - - // copy GOOGLE_CLOUD_LOCATION - if (process.env['GOOGLE_CLOUD_LOCATION']) { - args.push( - '--env', - `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`, - ); - } - - // copy GEMINI_MODEL - if (process.env['GEMINI_MODEL']) { - args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`); - } - - // copy TERM and COLORTERM to try to maintain terminal setup - if (process.env['TERM']) { - args.push('--env', `TERM=${process.env['TERM']}`); - } - if (process.env['COLORTERM']) { - args.push('--env', `COLORTERM=${process.env['COLORTERM']}`); - } - - // Pass through IDE mode environment variables - for (const envVar of [ - 'QWEN_CODE_IDE_SERVER_PORT', - 'QWEN_CODE_IDE_WORKSPACE_PATH', - 'TERM_PROGRAM', - ]) { - if (process.env[envVar]) { - args.push('--env', `${envVar}=${process.env[envVar]}`); - } - } - - // copy VIRTUAL_ENV if under working directory - // also mount-replace VIRTUAL_ENV directory with /sandbox.venv - // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) - // directory will be empty if not set up, which is still preferable to having host binaries - if ( - process.env['VIRTUAL_ENV'] - ?.toLowerCase() - .startsWith(workdir.toLowerCase()) - ) { - const sandboxVenvPath = path.resolve( - SETTINGS_DIRECTORY_NAME, - 'sandbox.venv', - ); - if (!fs.existsSync(sandboxVenvPath)) { - fs.mkdirSync(sandboxVenvPath, { recursive: true }); - } - args.push( - '--volume', - `${sandboxVenvPath}:${getContainerPath(process.env['VIRTUAL_ENV'])}`, - ); - args.push( - '--env', - `VIRTUAL_ENV=${getContainerPath(process.env['VIRTUAL_ENV'])}`, - ); - } - - // copy additional environment variables from SANDBOX_ENV - if (process.env['SANDBOX_ENV']) { - for (let env of process.env['SANDBOX_ENV'].split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - console.error(`SANDBOX_ENV: ${env}`); - args.push('--env', env); - } else { - throw new FatalSandboxError( - 'SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); - } - } - } - } - - // copy NODE_OPTIONS - const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; - const allNodeOptions = [ - ...(existingNodeOptions ? [existingNodeOptions] : []), - ...nodeArgs, - ].join(' '); - - if (allNodeOptions.length > 0) { - args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); - } - - // set SANDBOX as container name - args.push('--env', `SANDBOX=${containerName}`); - - // for podman only, use empty --authfile to skip unnecessary auth refresh overhead - if (config.command === 'podman') { - const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); - fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); - args.push('--authfile', emptyAuthFilePath); - } - - // Determine if the current user's UID/GID should be passed to the sandbox. - // See shouldUseCurrentUserInSandbox for more details. - let userFlag = ''; - const finalEntrypoint = entrypoint(workdir, cliArgs); - - if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { - args.push('--user', 'root'); - userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { - // For the user-creation logic to work, the container must start as root. - // The entrypoint script then handles dropping privileges to the correct user. - args.push('--user', 'root'); - - const uid = execSync('id -u').toString().trim(); - const gid = execSync('id -g').toString().trim(); - - // Instead of passing --user to the main sandbox container, we let it - // start as root, then create a user with the host's UID/GID, and - // finally switch to that user to run the gemini process. This is - // necessary on Linux to ensure the user exists within the - // container's /etc/passwd file, which is required by os.userInfo(). - const username = 'gemini'; - const homeDir = getContainerPath(os.homedir()); - - const setupUserCommands = [ - // Use -f with groupadd to avoid errors if the group already exists. - `groupadd -f -g ${gid} ${username}`, - // Create user only if it doesn't exist. Use -o for non-unique UID. - `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, - ].join(' && '); - - const originalCommand = finalEntrypoint[2]; - const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); - - // Use `su -p` to preserve the environment. - const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; - - // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. - finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; - - // We still need userFlag for the simpler proxy container, which does not have this issue. - userFlag = `--user ${uid}:${gid}`; - // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. - args.push('--env', `HOME=${os.homedir()}`); - } - - // push container image name - args.push(image); - - // push container entrypoint (including args) - args.push(...finalEntrypoint); - - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - - if (proxyCommand) { - // run proxyCommand in its own container - const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; - const isWindows = os.platform() === 'win32'; - const proxyShell = isWindows ? 'cmd.exe' : 'bash'; - const proxyShellArgs = isWindows - ? ['/c', proxyContainerCommand] - : ['-c', proxyContainerCommand]; // Note: CodeQL flags this as js/shell-command-injection-from-environment. - // This is intentional - CLI tool executes user-provided proxy commands in container. - proxyProcess = spawn(proxyShell, proxyShellArgs, { + // This is intentional - CLI tool executes user-provided proxy commands. + proxyProcess = spawn('bash', ['-c', proxyCommand], { stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); // install handlers to stop proxy on exit/signal const stopProxy = () => { - console.log('stopping proxy container ...'); - execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); + writeStdoutLine('stopping proxy ...'); + if (proxyProcess?.pid) { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } }; process.on('exit', stopProxy); process.on('SIGINT', stopProxy); @@ -807,52 +306,538 @@ export async function start_sandbox( // console.info(data.toString()); // }); proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString().trim()); + writeStderrLine(data.toString()); }); proxyProcess.on('close', (code, signal) => { if (sandboxProcess?.pid) { process.kill(-sandboxProcess.pid, 'SIGTERM'); } throw new FatalSandboxError( - `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, + `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, ); }); - console.log('waiting for proxy to start ...'); + writeStdoutLine('waiting for proxy to start ...'); await execAsync( `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, ); - // connect proxy container to sandbox network - // (workaround for older versions of docker that don't support multiple --network args) - await execAsync( - `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, - ); } - // spawn child and let it inherit stdio process.stdin.pause(); sandboxProcess = spawn(config.command, args, { stdio: 'inherit', }); - - return new Promise((resolve, reject) => { - sandboxProcess.on('error', (err) => { - console.error('Sandbox process error:', err); - reject(err); - }); - - sandboxProcess?.on('close', (code, signal) => { + return new Promise((resolve, reject) => { + sandboxProcess?.on('error', reject); + sandboxProcess?.on('close', (code) => { process.stdin.resume(); - if (code !== 0 && code !== null) { - console.error( - `Sandbox process exited with code: ${code}, signal: ${signal}`, - ); - } resolve(code ?? 1); }); }); - } finally { - patcher.cleanup(); } + + writeStderrLine(`hopping into sandbox (command: ${config.command}) ...`); + + // determine full path for gemini-cli to distinguish linked vs installed setting + const gcPath = fs.realpathSync(process.argv[1]); + + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); + + const image = config.image; + const workdir = path.resolve(process.cwd()); + const containerWorkdir = getContainerPath(workdir); + + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // + // note this can only be done with binary linked from gemini-cli repo + if (process.env['BUILD_SANDBOX']) { + if (!gcPath.includes('qwen-code/packages/')) { + throw new FatalSandboxError( + 'Cannot build sandbox using installed Qwen Code binary; ' + + 'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.', + ); + } else { + writeStderrLine('building sandbox ...'); + const gcRoot = gcPath.split('/packages/')[0]; + // if project folder has sandbox.Dockerfile under project settings folder, use that + let buildArgs = ''; + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + if (isCustomProjectSandbox) { + writeStderrLine(`using ${projectSandboxDockerfile} for sandbox`); + buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; + } + execSync( + `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, + { + stdio: 'inherit', + env: { + ...process.env, + GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) + }, + }, + ); + } + } + + // stop if image is missing + if (!(await ensureSandboxImageIsPresent(config.command, image))) { + const remedy = + image === LOCAL_DEV_SANDBOX_IMAGE_NAME + ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' + : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; + throw new FatalSandboxError( + `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, + ); + } + + // use interactive mode and auto-remove container on exit + // run init binary inside container to forward signals & reap zombies + const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; + + // add custom flags from SANDBOX_FLAGS + if (process.env['SANDBOX_FLAGS']) { + const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( + (f): f is string => typeof f === 'string', + ); + args.push(...flags); + } + + // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container + if (process.stdin.isTTY) { + args.push('-t'); + } + + // allow access to host.docker.internal + args.push('--add-host', 'host.docker.internal:host-gateway'); + + // mount current directory as working directory in sandbox (set via --workdir) + args.push('--volume', `${workdir}:${containerWorkdir}`); + + // mount user settings directory inside container, after creating if missing + // note user/home changes inside sandbox and we mount at BOTH paths for consistency + const userSettingsDirOnHost = USER_SETTINGS_DIR; + const userSettingsDirInSandbox = getContainerPath( + `/home/node/${SETTINGS_DIRECTORY_NAME}`, + ); + if (!fs.existsSync(userSettingsDirOnHost)) { + fs.mkdirSync(userSettingsDirOnHost); + } + args.push('--volume', `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`); + if (userSettingsDirInSandbox !== userSettingsDirOnHost) { + args.push( + '--volume', + `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, + ); + } + + // mount os.tmpdir() as os.tmpdir() inside container + args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); + + // mount gcloud config directory if it exists + const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); + if (fs.existsSync(gcloudConfigDir)) { + args.push( + '--volume', + `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, + ); + } + + // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set + if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) { + const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + if (fs.existsSync(adcFile)) { + args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); + args.push( + '--env', + `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, + ); + } + } + + // mount paths listed in SANDBOX_MOUNTS + if (process.env['SANDBOX_MOUNTS']) { + for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) { + if (mount.trim()) { + // parse mount as from:to:opts + let [from, to, opts] = mount.trim().split(':'); + to = to || from; // default to mount at same path inside container + opts = opts || 'ro'; // default to read-only + mount = `${from}:${to}:${opts}`; + // check that from path is absolute + if (!path.isAbsolute(from)) { + throw new FatalSandboxError( + `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + ); + } + // check that from path exists on host + if (!fs.existsSync(from)) { + throw new FatalSandboxError( + `Missing mount path '${from}' listed in SANDBOX_MOUNTS`, + ); + } + writeStderrLine(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); + args.push('--volume', mount); + } + } + } + + // expose env-specified ports on the sandbox + ports().forEach((p) => args.push('--publish', `${p}:${p}`)); + + // if DEBUG is set, expose debugging port + if (process.env['DEBUG']) { + const debugPort = process.env['DEBUG_PORT'] || '9229'; + args.push(`--publish`, `${debugPort}:${debugPort}`); + } + + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set + const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + + if (proxyCommand) { + let proxy = + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'] || + 'http://localhost:8877'; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } + const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; + if (noProxy) { + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); + } + + // if using proxy, switch to internal networking through proxy + if (proxy) { + execSync( + `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + // if proxy command is set, create a separate network w/ host access (i.e. non-internal) + // we will run proxy in its own container connected to both host network and internal network + // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation + if (proxyCommand) { + execSync( + `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, + ); + } + } + } + + // name container after image, plus random suffix to avoid conflicts + const imageName = parseImageName(image); + const isIntegrationTest = + process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; + let containerName; + if (isIntegrationTest) { + containerName = `gemini-cli-integration-test-${randomBytes(4).toString( + 'hex', + )}`; + writeStdoutLine(`ContainerName: ${containerName}`); + } else { + let index = 0; + const containerNameCheck = execSync( + `${config.command} ps -a --format "{{.Names}}"`, + ) + .toString() + .trim(); + while (containerNameCheck.includes(`${imageName}-${index}`)) { + index++; + } + containerName = `${imageName}-${index}`; + writeStdoutLine(`ContainerName (regular): ${containerName}`); + } + args.push('--name', containerName, '--hostname', containerName); + + // copy GEMINI_CLI_TEST_VAR for integration tests + if (process.env['GEMINI_CLI_TEST_VAR']) { + args.push( + '--env', + `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`, + ); + } + + // copy GEMINI_API_KEY(s) + if (process.env['GEMINI_API_KEY']) { + args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`); + } + if (process.env['GOOGLE_API_KEY']) { + args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`); + } + + // copy OPENAI_API_KEY and related env vars for Qwen + if (process.env['OPENAI_API_KEY']) { + args.push('--env', `OPENAI_API_KEY=${process.env['OPENAI_API_KEY']}`); + } + // copy TAVILY_API_KEY for web search tool + if (process.env['TAVILY_API_KEY']) { + args.push('--env', `TAVILY_API_KEY=${process.env['TAVILY_API_KEY']}`); + } + if (process.env['OPENAI_BASE_URL']) { + args.push('--env', `OPENAI_BASE_URL=${process.env['OPENAI_BASE_URL']}`); + } + if (process.env['OPENAI_MODEL']) { + args.push('--env', `OPENAI_MODEL=${process.env['OPENAI_MODEL']}`); + } + + // copy GOOGLE_GENAI_USE_VERTEXAI + if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) { + args.push( + '--env', + `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`, + ); + } + + // copy GOOGLE_GENAI_USE_GCA + if (process.env['GOOGLE_GENAI_USE_GCA']) { + args.push( + '--env', + `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`, + ); + } + + // copy GOOGLE_CLOUD_PROJECT + if (process.env['GOOGLE_CLOUD_PROJECT']) { + args.push( + '--env', + `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`, + ); + } + + // copy GOOGLE_CLOUD_LOCATION + if (process.env['GOOGLE_CLOUD_LOCATION']) { + args.push( + '--env', + `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`, + ); + } + + // copy GEMINI_MODEL + if (process.env['GEMINI_MODEL']) { + args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`); + } + + // copy TERM and COLORTERM to try to maintain terminal setup + if (process.env['TERM']) { + args.push('--env', `TERM=${process.env['TERM']}`); + } + if (process.env['COLORTERM']) { + args.push('--env', `COLORTERM=${process.env['COLORTERM']}`); + } + + // Pass through IDE mode environment variables + for (const envVar of [ + 'QWEN_CODE_IDE_SERVER_PORT', + 'QWEN_CODE_IDE_WORKSPACE_PATH', + 'TERM_PROGRAM', + ]) { + if (process.env[envVar]) { + args.push('--env', `${envVar}=${process.env[envVar]}`); + } + } + + // copy VIRTUAL_ENV if under working directory + // also mount-replace VIRTUAL_ENV directory with /sandbox.venv + // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) + // directory will be empty if not set up, which is still preferable to having host binaries + if ( + process.env['VIRTUAL_ENV']?.toLowerCase().startsWith(workdir.toLowerCase()) + ) { + const sandboxVenvPath = path.resolve( + SETTINGS_DIRECTORY_NAME, + 'sandbox.venv', + ); + if (!fs.existsSync(sandboxVenvPath)) { + fs.mkdirSync(sandboxVenvPath, { recursive: true }); + } + args.push( + '--volume', + `${sandboxVenvPath}:${getContainerPath(process.env['VIRTUAL_ENV'])}`, + ); + args.push( + '--env', + `VIRTUAL_ENV=${getContainerPath(process.env['VIRTUAL_ENV'])}`, + ); + } + + // copy additional environment variables from SANDBOX_ENV + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + writeStderrLine(`SANDBOX_ENV: ${env}`); + args.push('--env', env); + } else { + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + } + } + } + } + + // copy NODE_OPTIONS + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + + if (allNodeOptions.length > 0) { + args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); + } + + // set SANDBOX as container name + args.push('--env', `SANDBOX=${containerName}`); + + // for podman only, use empty --authfile to skip unnecessary auth refresh overhead + if (config.command === 'podman') { + const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); + fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); + args.push('--authfile', emptyAuthFilePath); + } + + // Determine if the current user's UID/GID should be passed to the sandbox. + // See shouldUseCurrentUserInSandbox for more details. + let userFlag = ''; + const finalEntrypoint = entrypoint(workdir, cliArgs); + + if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { + args.push('--user', 'root'); + userFlag = '--user root'; + } else if (await shouldUseCurrentUserInSandbox()) { + // For the user-creation logic to work, the container must start as root. + // The entrypoint script then handles dropping privileges to the correct user. + args.push('--user', 'root'); + + const uid = execSync('id -u').toString().trim(); + const gid = execSync('id -g').toString().trim(); + + // Instead of passing --user to the main sandbox container, we let it + // start as root, then create a user with the host's UID/GID, and + // finally switch to that user to run the gemini process. This is + // necessary on Linux to ensure the user exists within the + // container's /etc/passwd file, which is required by os.userInfo(). + const username = 'gemini'; + const homeDir = getContainerPath(os.homedir()); + + const setupUserCommands = [ + // Use -f with groupadd to avoid errors if the group already exists. + `groupadd -f -g ${gid} ${username}`, + // Create user only if it doesn't exist. Use -o for non-unique UID. + `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, + ].join(' && '); + + const originalCommand = finalEntrypoint[2]; + const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); + + // Use `su -p` to preserve the environment. + const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; + + // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. + finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; + + // We still need userFlag for the simpler proxy container, which does not have this issue. + userFlag = `--user ${uid}:${gid}`; + // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. + args.push('--env', `HOME=${os.homedir()}`); + } + + // push container image name + args.push(image); + + // push container entrypoint (including args) + args.push(...finalEntrypoint); + + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + + if (proxyCommand) { + // run proxyCommand in its own container + const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; + const isWindows = os.platform() === 'win32'; + const proxyShell = isWindows ? 'cmd.exe' : 'bash'; + const proxyShellArgs = isWindows + ? ['/c', proxyContainerCommand] + : ['-c', proxyContainerCommand]; + // Note: CodeQL flags this as js/shell-command-injection-from-environment. + // This is intentional - CLI tool executes user-provided proxy commands in container. + proxyProcess = spawn(proxyShell, proxyShellArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + // install handlers to stop proxy on exit/signal + const stopProxy = () => { + writeStdoutLine('stopping proxy container ...'); + execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); + }; + process.on('exit', stopProxy); + process.on('SIGINT', stopProxy); + process.on('SIGTERM', stopProxy); + + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + writeStderrLine(data.toString().trim()); + }); + proxyProcess.on('close', (code, signal) => { + if (sandboxProcess?.pid) { + process.kill(-sandboxProcess.pid, 'SIGTERM'); + } + throw new FatalSandboxError( + `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, + ); + }); + writeStdoutLine('waiting for proxy to start ...'); + await execAsync( + `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, + ); + // connect proxy container to sandbox network + // (workaround for older versions of docker that don't support multiple --network args) + await execAsync( + `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, + ); + } + + // spawn child and let it inherit stdio + process.stdin.pause(); + sandboxProcess = spawn(config.command, args, { + stdio: 'inherit', + }); + + return new Promise((resolve, reject) => { + sandboxProcess.on('error', (err) => { + writeStderrLine(`Sandbox process error: ${err}`); + reject(err); + }); + + sandboxProcess?.on('close', (code, signal) => { + process.stdin.resume(); + if (code !== 0 && code !== null) { + writeStderrLine( + `Sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(code ?? 1); + }); + }); } // Helper functions to ensure sandbox image is present @@ -869,7 +854,7 @@ async function imageExists(sandbox: string, image: string): Promise { } checkProcess.on('error', (err) => { - console.warn( + writeStderrLine( `Failed to start '${sandbox}' command for image check: ${err.message}`, ); resolve(false); @@ -887,7 +872,7 @@ async function imageExists(sandbox: string, image: string): Promise { } async function pullImage(sandbox: string, image: string): Promise { - console.info(`Attempting to pull image ${image} using ${sandbox}...`); + writeStdoutLine(`Attempting to pull image ${image} using ${sandbox}...`); return new Promise((resolve) => { const args = ['pull', image]; const pullProcess = spawn(sandbox, args, { stdio: 'pipe' }); @@ -895,16 +880,16 @@ async function pullImage(sandbox: string, image: string): Promise { let stderrData = ''; const onStdoutData = (data: Buffer) => { - console.info(data.toString().trim()); // Show pull progress + writeStdoutLine(data.toString().trim()); // Show pull progress }; const onStderrData = (data: Buffer) => { stderrData += data.toString(); - console.error(data.toString().trim()); // Show pull errors/info from the command itself + writeStderrLine(data.toString().trim()); // Show pull errors/info from the command itself }; const onError = (err: Error) => { - console.warn( + writeStderrLine( `Failed to start '${sandbox} pull ${image}' command: ${err.message}`, ); cleanup(); @@ -913,11 +898,11 @@ async function pullImage(sandbox: string, image: string): Promise { const onClose = (code: number | null) => { if (code === 0) { - console.info(`Successfully pulled image ${image}.`); + writeStdoutLine(`Successfully pulled image ${image}.`); cleanup(); resolve(true); } else { - console.warn( + writeStderrLine( `Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`, ); if (stderrData.trim()) { @@ -957,13 +942,13 @@ async function ensureSandboxImageIsPresent( sandbox: string, image: string, ): Promise { - console.info(`Checking for sandbox image: ${image}`); + writeStdoutLine(`Checking for sandbox image: ${image}`); if (await imageExists(sandbox, image)) { - console.info(`Sandbox image ${image} found locally.`); + writeStdoutLine(`Sandbox image ${image} found locally.`); return true; } - console.info(`Sandbox image ${image} not found locally.`); + writeStdoutLine(`Sandbox image ${image} not found locally.`); if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) { // user needs to build the image themselves return false; @@ -972,17 +957,17 @@ async function ensureSandboxImageIsPresent( if (await pullImage(sandbox, image)) { // After attempting to pull, check again to be certain if (await imageExists(sandbox, image)) { - console.info(`Sandbox image ${image} is now available after pulling.`); + writeStdoutLine(`Sandbox image ${image} is now available after pulling.`); return true; } else { - console.warn( + writeStderrLine( `Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`, ); return false; } } - console.error( + writeStderrLine( `Failed to obtain sandbox image ${image} after check and pull attempt.`, ); return false; // Pull command failed or image still not present diff --git a/packages/cli/src/utils/stdioHelpers.ts b/packages/cli/src/utils/stdioHelpers.ts new file mode 100644 index 000000000..ca5e30f9e --- /dev/null +++ b/packages/cli/src/utils/stdioHelpers.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility functions for writing to stdout/stderr in CLI commands. + * + * These helpers are used instead of console.log/console.error in standalone + * CLI commands (like `qwen extensions list`) where the output IS the user-facing + * result, not debug logging. + * + * For debug/diagnostic logging, use `createDebugLogger()` from @qwen-code/qwen-code-core. + */ + +/** + * Writes a message to stdout with a trailing newline. + * Use for normal command output that the user expects to see. + * Avoids double newlines if the message already ends with one. + */ +export const writeStdoutLine = (message: string): void => { + process.stdout.write(message.endsWith('\n') ? message : `${message}\n`); +}; + +/** + * Writes a message to stderr with a trailing newline. + * Use for error messages in CLI commands. + * Avoids double newlines if the message already ends with one. + */ +export const writeStderrLine = (message: string): void => { + process.stderr.write(message.endsWith('\n') ? message : `${message}\n`); +}; + +/** + * Clears the terminal screen. + * Use instead of console.clear() to satisfy no-console lint rules. + */ +export const clearScreen = (): void => { + console.clear(); +}; diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 11dd3289f..2df4156ca 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -14,6 +14,14 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter. import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js'; import * as cleanupModule from './utils/cleanup.js'; +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('./utils/stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: vi.fn(), + clearScreen: vi.fn(), +})); + type ModelsConfig = ReturnType; // Helper to create a mock Config with modelsConfig @@ -42,7 +50,6 @@ describe('validateNonInterActiveAuth', () => { let originalEnvQwenOauth: string | undefined; let originalEnvGoogleApiKey: string | undefined; let originalEnvAnthropicApiKey: string | undefined; - let consoleErrorSpy: ReturnType; let processExitSpy: ReturnType>; let refreshAuthMock: ReturnType; let mockSettings: LoadedSettings; @@ -62,7 +69,7 @@ describe('validateNonInterActiveAuth', () => { delete process.env['QWEN_OAUTH']; delete process.env['GOOGLE_API_KEY']; delete process.env['ANTHROPIC_API_KEY']; - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockWriteStderrLine.mockClear(); processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); }) as ReturnType>; @@ -149,7 +156,7 @@ describe('validateNonInterActiveAuth', () => { } catch (e) { expect((e as Error).message).toContain('process.exit(1) called'); } - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( expect.stringContaining('Missing API key'), ); expect(processExitSpy).toHaveBeenCalledWith(1); @@ -204,7 +211,7 @@ describe('validateNonInterActiveAuth', () => { } catch (e) { expect((e as Error).message).toContain('process.exit(1) called'); } - expect(consoleErrorSpy).toHaveBeenCalledWith('Auth error!'); + expect(mockWriteStderrLine).toHaveBeenCalledWith('Auth error!'); expect(processExitSpy).toHaveBeenCalledWith(1); }); @@ -226,7 +233,7 @@ describe('validateNonInterActiveAuth', () => { ); expect(validateAuthMethodSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); // refreshAuth is called with the authType from config.getModelsConfig().getCurrentAuthType() expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH); @@ -272,7 +279,7 @@ describe('validateNonInterActiveAuth', () => { } catch (e) { expect((e as Error).message).toContain('process.exit(1) called'); } - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(mockWriteStderrLine).toHaveBeenCalledWith( 'The configured auth type is qwen-oauth, but the current auth type is openai. Please re-authenticate with the correct type.', ); expect(processExitSpy).toHaveBeenCalledWith(1); @@ -330,7 +337,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('emits error result and exits when enforced auth mismatches current auth', async () => { @@ -369,7 +376,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('emits error result and exits when API key validation fails', async () => { @@ -406,7 +413,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); }); @@ -466,7 +473,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('emits error result and exits when enforced auth mismatches current auth', async () => { @@ -506,7 +513,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); it('emits error result and exits when API key validation fails', async () => { @@ -544,7 +551,7 @@ describe('validateNonInterActiveAuth', () => { }); expect(runExitCleanupMock).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index ce60264c0..0a7080aea 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -11,6 +11,7 @@ import { type LoadedSettings } from './config/settings.js'; import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; import { runExitCleanup } from './utils/cleanup.js'; +import { writeStderrLine } from './utils/stdioHelpers.js'; export async function validateNonInteractiveAuth( useExternalAuth: boolean | undefined, @@ -76,7 +77,7 @@ export async function validateNonInteractiveAuth( } // For other modes (text), use existing error handling - console.error(error instanceof Error ? error.message : String(error)); + writeStderrLine(error instanceof Error ? error.message : String(error)); process.exit(1); } } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f064de369..eff0e7034 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -107,6 +107,11 @@ import { } from '../services/sessionService.js'; import { randomUUID } from 'node:crypto'; import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js'; +import { + createDebugLogger, + setDebugLogSession, + type DebugLogger, +} from '../utils/debugLogger.js'; import { ModelsConfig, @@ -409,6 +414,7 @@ export interface ConfigInitializeOptions { export class Config { private sessionId: string; private sessionData?: ResumedSessionData; + private debugLogger: DebugLogger; private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; @@ -518,6 +524,8 @@ export class Config { constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); this.sessionData = params.sessionData; + setDebugLogSession(this); + this.debugLogger = createDebugLogger(); this.embeddingModel = params.embeddingModel ?? DEFAULT_QWEN_EMBEDDING_MODEL; this.fileSystemService = new StandardFileSystemService(); this.sandbox = params.sandbox; @@ -678,6 +686,7 @@ export class Config { throw Error('Config was already initialized'); } this.initialized = true; + this.debugLogger.info('Config initialization started'); // Initialize centralized FileDiscoveryService this.getFileService(); @@ -687,11 +696,13 @@ export class Config { this.promptRegistry = new PromptRegistry(); this.extensionManager.setConfig(this); await this.extensionManager.refreshCache(); + this.debugLogger.debug('Extension manager initialized'); this.subagentManager = new SubagentManager(this); if (this.getExperimentalSkills()) { this.skillManager = new SkillManager(this); await this.skillManager.startWatching(); + this.debugLogger.debug('Skill manager initialized'); } // Load session subagents if they were provided before initialization @@ -702,17 +713,23 @@ export class Config { await this.extensionManager.refreshCache(); await this.refreshHierarchicalMemory(); + this.debugLogger.debug('Hierarchical memory loaded'); this.toolRegistry = await this.createToolRegistry( options?.sendSdkMcpMessage, ); + this.debugLogger.info( + `Tool registry initialized with ${this.toolRegistry.getAllToolNames().length} tools`, + ); await this.geminiClient.initialize(); + this.debugLogger.info('Gemini client initialized'); // Detect and capture runtime model snapshot (from CLI/ENV/credentials) this.modelsConfig.detectAndCaptureRuntimeModel(); logStartSession(this, new StartSessionEvent(this)); + this.debugLogger.info('Config initialization completed'); } async refreshHierarchicalMemory(): Promise { @@ -721,7 +738,6 @@ export class Config { this.shouldLoadMemoryFromIncludeDirectories() ? this.getWorkspaceContext().getDirectories() : [], - this.getDebugMode(), this.getFileService(), this.getExtensionContextFilePaths(), this.isTrustedFolder(), @@ -818,6 +834,10 @@ export class Config { return this.sessionId; } + getDebugLogger(): DebugLogger { + return this.debugLogger; + } + /** * Starts a new session and resets session-scoped services. */ @@ -827,6 +847,8 @@ export class Config { ): string { this.sessionId = sessionId ?? randomUUID(); this.sessionData = sessionData; + setDebugLogSession(this); + this.debugLogger = createDebugLogger(); this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) : undefined; @@ -1059,7 +1081,7 @@ export class Config { } } catch (error) { // Log but don't throw - cleanup should be best-effort - console.error('Error during Config shutdown:', error); + this.debugLogger.error('Error during Config shutdown:', error); } } @@ -1630,10 +1652,9 @@ export class Config { if (!toolName) { // Log warning and skip this tool instead of crashing - console.warn( - `[Config] Skipping tool registration: ${className} is missing static Name property. ` + - `Tools must define a static Name property to be registered. ` + - `Location: config.ts:registerCoreTool`, + this.debugLogger.warn( + `Skipping tool registration: ${className} is missing static Name property. ` + + `Tools must define a static Name property to be registered.`, ); return; } @@ -1642,8 +1663,8 @@ export class Config { try { registry.registerTool(new ToolClass(...args)); } catch (error) { - console.error( - `[Config] Failed to register tool ${className} (${toolName}):`, + this.debugLogger.error( + `Failed to register tool ${className} (${toolName}):`, error, ); throw error; // Re-throw after logging context @@ -1704,7 +1725,9 @@ export class Config { } await registry.discoverAllTools(); - console.debug('ToolRegistry created', registry.getAllToolNames()); + this.debugLogger.debug( + `ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`, + ); return registry; } } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 49b59ca1f..8ef0283c5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -16,6 +16,7 @@ const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; const IDE_DIR_NAME = 'ide'; +const DEBUG_DIR_NAME = 'debug'; export class Storage { private readonly targetDir: string; @@ -60,6 +61,14 @@ export class Storage { return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); } + static getGlobalDebugDir(): string { + return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME); + } + + static getDebugLogPath(sessionId: string): string { + return path.join(Storage.getGlobalDebugDir(), `${sessionId}.txt`); + } + static getGlobalIdeDir(): string { return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME); } diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 5fc51b109..3fcd4b96d 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -30,6 +30,9 @@ import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { AnthropicContentConverter } from './converter.js'; import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js'; import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('ANTHROPIC'); type StreamingBlockState = { type: string; @@ -122,7 +125,7 @@ export class AnthropicContentGenerator implements ContentGenerator { totalTokens: result.totalTokens, }; } catch (error) { - console.warn( + debugLogger.warn( 'Failed to calculate tokens with tokenizer, ' + 'falling back to simple method:', error, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 3a912c090..9f3625c38 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -15,6 +15,9 @@ import type { // Config import { ApprovalMode, type Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('CLIENT'); // Core modules import type { ContentGenerator } from './contentGenerator.js'; @@ -259,9 +262,7 @@ export class GeminiClient { contextLines.join('\n'), ]; - if (this.config.getDebugMode()) { - console.log(contextParts.join('\n')); - } + debugLogger.debug(contextParts.join('\n')); return { contextParts, newIdeContext: currentIdeContext, @@ -391,9 +392,7 @@ export class GeminiClient { changeLines.join('\n'), ]; - if (this.config.getDebugMode()) { - console.log(contextParts.join('\n')); - } + debugLogger.debug(contextParts.join('\n')); return { contextParts, newIdeContext: currentIdeContext, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 3fefacbc6..e643bba62 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -18,6 +18,9 @@ import type { AnyToolInvocation, ChatRecordingService, } from '../index.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TOOL_SCHEDULER'); import { ToolConfirmationOutcome, ApprovalMode, @@ -1327,7 +1330,7 @@ export class CoreToolScheduler { this.setStatusInternal(pendingTool.request.callId, 'scheduled'); } } catch (error) { - console.error( + debugLogger.error( `Error checking confirmation for tool ${pendingTool.request.callId}:`, error, ); diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index 0b506b4c4..de3fc3f78 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -35,22 +35,23 @@ const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); -const TEST_GEMINI_DIR = path.join( - os.homedir(), - GEMINI_DIR_NAME, - TMP_DIR_NAME, - hash, -); +const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); -const TEST_LOG_FILE_PATH = path.join(TEST_GEMINI_DIR, LOG_FILE_NAME); -const TEST_CHECKPOINT_FILE_PATH = path.join( - TEST_GEMINI_DIR, - CHECKPOINT_FILE_NAME, -); +let originalHome: string | undefined; +let testGeminiDir: string; +let testLogFilePath: string; +let testCheckpointFilePath: string; + +const setTestPaths = () => { + testGeminiDir = path.join(os.homedir(), GEMINI_DIR_NAME, TMP_DIR_NAME, hash); + testLogFilePath = path.join(testGeminiDir, LOG_FILE_NAME); + testCheckpointFilePath = path.join(testGeminiDir, CHECKPOINT_FILE_NAME); +}; async function cleanupLogAndCheckpointFiles() { try { - await fs.rm(TEST_GEMINI_DIR, { recursive: true, force: true }); + if (!testGeminiDir) return; + await fs.rm(testGeminiDir, { recursive: true, force: true }); } catch (_error) { // Ignore errors, as the directory may not exist, which is fine. } @@ -58,7 +59,7 @@ async function cleanupLogAndCheckpointFiles() { async function readLogFile(): Promise { try { - const content = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8'); + const content = await fs.readFile(testLogFilePath, 'utf-8'); return JSON.parse(content) as LogEntry[]; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { @@ -72,6 +73,20 @@ vi.mock('../utils/session.js', () => ({ sessionId: 'test-session-id', })); +vi.mock('../utils/debugLogger.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + createDebugLogger: () => ({ + debug: (...args: unknown[]) => console.debug(...args), + info: (...args: unknown[]) => console.info(...args), + warn: (...args: unknown[]) => console.warn(...args), + error: (...args: unknown[]) => console.error(...args), + }), + }; +}); + describe('Logger', () => { let logger: Logger; const testSessionId = 'test-session-id'; @@ -80,10 +95,13 @@ describe('Logger', () => { vi.resetAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); + originalHome = process.env['HOME']; + process.env['HOME'] = TEST_HOME_DIR; + setTestPaths(); // Clean up before the test await cleanupLogAndCheckpointFiles(); // Ensure the directory exists for the test - await fs.mkdir(TEST_GEMINI_DIR, { recursive: true }); + await fs.mkdir(testGeminiDir, { recursive: true }); logger = new Logger(testSessionId, new Storage(process.cwd())); await logger.initialize(); }); @@ -96,6 +114,11 @@ describe('Logger', () => { await cleanupLogAndCheckpointFiles(); vi.useRealTimers(); vi.restoreAllMocks(); + if (originalHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = originalHome; + } }); afterAll(async () => { @@ -106,13 +129,13 @@ describe('Logger', () => { describe('initialize', () => { it('should create .gemini directory and an empty log file if none exist', async () => { const dirExists = await fs - .access(TEST_GEMINI_DIR) + .access(testGeminiDir) .then(() => true) .catch(() => false); expect(dirExists).toBe(true); const fileExists = await fs - .access(TEST_LOG_FILE_PATH) + .access(testLogFilePath) .then(() => true) .catch(() => false); expect(fileExists).toBe(true); @@ -148,7 +171,7 @@ describe('Logger', () => { }, ]; await fs.writeFile( - TEST_LOG_FILE_PATH, + testLogFilePath, JSON.stringify(existingLogs, null, 2), ); const newLogger = new Logger( @@ -172,7 +195,7 @@ describe('Logger', () => { }, ]; await fs.writeFile( - TEST_LOG_FILE_PATH, + testLogFilePath, JSON.stringify(existingLogs, null, 2), ); const newLogger = new Logger('a-new-session', new Storage(process.cwd())); @@ -195,21 +218,14 @@ describe('Logger', () => { }); it('should handle invalid JSON in log file by backing it up and starting fresh', async () => { - await fs.writeFile(TEST_LOG_FILE_PATH, 'invalid json'); - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + await fs.writeFile(testLogFilePath, 'invalid json'); const newLogger = new Logger(testSessionId, new Storage(process.cwd())); await newLogger.initialize(); - expect(consoleDebugSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid JSON in log file'), - expect.any(SyntaxError), - ); const logContent = await readLogFile(); expect(logContent).toEqual([]); - const dirContents = await fs.readdir(TEST_GEMINI_DIR); + const dirContents = await fs.readdir(testGeminiDir); expect( dirContents.some( (f) => @@ -220,23 +236,14 @@ describe('Logger', () => { }); it('should handle non-array JSON in log file by backing it up and starting fresh', async () => { - await fs.writeFile( - TEST_LOG_FILE_PATH, - JSON.stringify({ not: 'an array' }), - ); - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + await fs.writeFile(testLogFilePath, JSON.stringify({ not: 'an array' })); const newLogger = new Logger(testSessionId, new Storage(process.cwd())); await newLogger.initialize(); - expect(consoleDebugSpy).toHaveBeenCalledWith( - `Log file at ${TEST_LOG_FILE_PATH} is not a valid JSON array. Starting with empty logs.`, - ); const logContent = await readLogFile(); expect(logContent).toEqual([]); - const dirContents = await fs.readdir(TEST_GEMINI_DIR); + const dirContents = await fs.readdir(testGeminiDir); expect( dirContents.some( (f) => @@ -283,13 +290,7 @@ describe('Logger', () => { new Storage(process.cwd()), ); uninitializedLogger.close(); // Ensure it's treated as uninitialized - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); await uninitializedLogger.logMessage(MessageSenderType.USER, 'test'); - expect(consoleDebugSpy).toHaveBeenCalledWith( - 'Logger not initialized or session ID missing. Cannot log message.', - ); expect((await readLogFile()).length).toBe(0); uninitializedLogger.close(); }); @@ -339,18 +340,11 @@ describe('Logger', () => { it('should not throw, not increment messageId, and log error if writing to file fails', async () => { vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('Disk full')); - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); const initialMessageId = logger['messageId']; const initialLogCount = logger['logs'].length; await logger.logMessage(MessageSenderType.USER, 'test fail write'); - expect(consoleDebugSpy).toHaveBeenCalledWith( - 'Error writing to log file:', - expect.any(Error), - ); expect(logger['messageId']).toBe(initialMessageId); // Not incremented expect(logger['logs'].length).toBe(initialLogCount); // Log not added to in-memory cache }); @@ -439,7 +433,7 @@ describe('Logger', () => { ])('should save a checkpoint', async ({ tag, encodedTag }) => { await logger.saveCheckpoint(conversation, tag); const taggedFilePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${encodedTag}.json`, ); const fileContent = await fs.readFile(taggedFilePath, 'utf-8'); @@ -452,16 +446,10 @@ describe('Logger', () => { new Storage(process.cwd()), ); uninitializedLogger.close(); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); await expect( uninitializedLogger.saveCheckpoint(conversation, 'tag'), ).resolves.not.toThrow(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', - ); }); }); @@ -473,7 +461,7 @@ describe('Logger', () => { beforeEach(async () => { await fs.writeFile( - TEST_CHECKPOINT_FILE_PATH, + testCheckpointFilePath, JSON.stringify(conversation, null, 2), ); }); @@ -502,7 +490,7 @@ describe('Logger', () => { { role: 'user', parts: [{ text: 'hello' }] }, ]; const taggedFilePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${encodedTag}.json`, ); await fs.writeFile( @@ -522,7 +510,7 @@ describe('Logger', () => { }); it('should return an empty array if the checkpoint file does not exist', async () => { - await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone + await fs.unlink(testCheckpointFilePath); // Ensure it's gone const loaded = await logger.loadCheckpoint('missing'); expect(loaded).toEqual([]); }); @@ -531,19 +519,12 @@ describe('Logger', () => { const tag = 'invalid-json-tag'; const encodedTag = 'invalid-json-tag'; const taggedFilePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${encodedTag}.json`, ); await fs.writeFile(taggedFilePath, 'invalid json'); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const loadedCheckpoint = await logger.loadCheckpoint(tag); expect(loadedCheckpoint).toEqual([]); - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to read or parse checkpoint file'), - expect.any(Error), - ); }); it('should return an empty array if logger is not initialized', async () => { @@ -552,14 +533,8 @@ describe('Logger', () => { new Storage(process.cwd()), ); uninitializedLogger.close(); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag'); expect(loadedCheckpoint).toEqual([]); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', - ); }); }); @@ -573,7 +548,7 @@ describe('Logger', () => { beforeEach(async () => { taggedFilePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${encodedTag}.json`, ); // Create a file to be deleted @@ -591,7 +566,7 @@ describe('Logger', () => { it('should delete both new and old checkpoint files if they exist', async () => { const oldTag = 'delete-me(old)'; const oldStylePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${oldTag}.json`, ); const newStylePath = logger['_checkpointPath'](oldTag); @@ -624,17 +599,10 @@ describe('Logger', () => { code: 'EACCES', }), ); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); await expect(logger.deleteCheckpoint(tag)).rejects.toThrow( 'EACCES: permission denied', ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Failed to delete checkpoint file ${taggedFilePath}:`, - expect.any(Error), - ); }); it('should return false if logger is not initialized', async () => { @@ -643,15 +611,9 @@ describe('Logger', () => { new Storage(process.cwd()), ); uninitializedLogger.close(); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const result = await uninitializedLogger.deleteCheckpoint(tag); expect(result).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Logger not initialized or checkpoint file path not set. Cannot delete checkpoint.', - ); }); }); @@ -662,7 +624,7 @@ describe('Logger', () => { beforeEach(() => { taggedFilePath = path.join( - TEST_GEMINI_DIR, + testGeminiDir, `checkpoint-${encodedTag}.json`, ); }); @@ -696,17 +658,10 @@ describe('Logger', () => { code: 'EACCES', }), ); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); await expect(logger.checkpointExists(tag)).rejects.toThrow( 'EACCES: permission denied', ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Failed to check checkpoint existence for path for tag "${tag}":`, - expect.any(Error), - ); }); }); @@ -721,10 +676,7 @@ describe('Logger', () => { { role: 'user', parts: [{ text: 'hello' }] }, ]; const tag = 'special(char)'; - const taggedFilePath = path.join( - TEST_GEMINI_DIR, - `checkpoint-${tag}.json`, - ); + const taggedFilePath = path.join(testGeminiDir, `checkpoint-${tag}.json`); await fs.writeFile( taggedFilePath, JSON.stringify(taggedConversation, null, 2), @@ -739,13 +691,7 @@ describe('Logger', () => { it('should reset logger state', async () => { await logger.logMessage(MessageSenderType.USER, 'A message'); logger.close(); - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); await logger.logMessage(MessageSenderType.USER, 'Another message'); - expect(consoleDebugSpy).toHaveBeenCalledWith( - 'Logger not initialized or session ID missing. Cannot log message.', - ); const messages = await logger.getPreviousUserMessages(); expect(messages).toEqual([]); expect(logger['initialized']).toBe(false); diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index e7be90415..ad414b03e 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { promises as fs } from 'node:fs'; import type { Content } from '@google/genai'; import type { Storage } from '../config/storage.js'; +import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js'; const LOG_FILE_NAME = 'logs.json'; @@ -74,12 +75,14 @@ export class Logger { private messageId = 0; // Instance-specific counter for the next messageId private initialized = false; private logs: LogEntry[] = []; // In-memory cache, ideally reflects the last known state of the file + private debugLogger: DebugLogger; constructor( sessionId: string, private readonly storage: Storage, ) { this.sessionId = sessionId; + this.debugLogger = createDebugLogger('LOGGER'); } private async _readLogFile(): Promise { @@ -90,7 +93,7 @@ export class Logger { const fileContent = await fs.readFile(this.logFilePath, 'utf-8'); const parsedLogs = JSON.parse(fileContent); if (!Array.isArray(parsedLogs)) { - console.debug( + this.debugLogger.debug( `Log file at ${this.logFilePath} is not a valid JSON array. Starting with empty logs.`, ); await this._backupCorruptedLogFile('malformed_array'); @@ -110,14 +113,14 @@ export class Logger { return []; } if (error instanceof SyntaxError) { - console.debug( + this.debugLogger.debug( `Invalid JSON in log file ${this.logFilePath}. Backing up and starting fresh.`, error, ); await this._backupCorruptedLogFile('invalid_json'); return []; } - console.debug( + this.debugLogger.debug( `Failed to read or parse log file ${this.logFilePath}:`, error, ); @@ -130,7 +133,7 @@ export class Logger { const backupPath = `${this.logFilePath}.${reason}.${Date.now()}.bak`; try { await fs.rename(this.logFilePath, backupPath); - console.debug(`Backed up corrupted log file to ${backupPath}`); + this.debugLogger.debug(`Backed up corrupted log file to ${backupPath}`); } catch (_backupError) { // If rename fails (e.g. file doesn't exist), no need to log an error here as the primary error (e.g. invalid JSON) is already handled. } @@ -165,7 +168,7 @@ export class Logger { : 0; this.initialized = true; } catch (err) { - console.error('Failed to initialize logger:', err); + this.debugLogger.error('Failed to initialize logger:', err); this.initialized = false; } } @@ -174,7 +177,9 @@ export class Logger { entryToAppend: LogEntry, ): Promise { if (!this.logFilePath) { - console.debug('Log file path not set. Cannot persist log entry.'); + this.debugLogger.debug( + 'Log file path not set. Cannot persist log entry.', + ); throw new Error('Log file path not set during update attempt.'); } @@ -182,7 +187,7 @@ export class Logger { try { currentLogsOnDisk = await this._readLogFile(); } catch (readError) { - console.debug( + this.debugLogger.debug( 'Critical error reading log file before append:', readError, ); @@ -213,7 +218,7 @@ export class Logger { ); if (entryExists) { - console.debug( + this.debugLogger.debug( `Duplicate log entry detected and skipped: session ${entryToAppend.sessionId}, messageId ${entryToAppend.messageId}`, ); this.logs = currentLogsOnDisk; // Ensure in-memory is synced with disk @@ -231,7 +236,7 @@ export class Logger { this.logs = currentLogsOnDisk; return entryToAppend; // Return the successfully appended entry } catch (error) { - console.debug('Error writing to log file:', error); + this.debugLogger.debug('Error writing to log file:', error); throw error; } } @@ -250,7 +255,7 @@ export class Logger { async logMessage(type: MessageSenderType, message: string): Promise { if (!this.initialized || this.sessionId === undefined) { - console.debug( + this.debugLogger.debug( 'Logger not initialized or session ID missing. Cannot log message.', ); return; @@ -322,7 +327,7 @@ export class Logger { async saveCheckpoint(conversation: Content[], tag: string): Promise { if (!this.initialized) { - console.error( + this.debugLogger.error( 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', ); return; @@ -332,13 +337,13 @@ export class Logger { try { await fs.writeFile(path, JSON.stringify(conversation, null, 2), 'utf-8'); } catch (error) { - console.error('Error writing to checkpoint file:', error); + this.debugLogger.error('Error writing to checkpoint file:', error); } } async loadCheckpoint(tag: string): Promise { if (!this.initialized) { - console.error( + this.debugLogger.error( 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', ); return []; @@ -349,7 +354,7 @@ export class Logger { const fileContent = await fs.readFile(path, 'utf-8'); const parsedContent = JSON.parse(fileContent); if (!Array.isArray(parsedContent)) { - console.warn( + this.debugLogger.warn( `Checkpoint file at ${path} is not a valid JSON array. Returning empty checkpoint.`, ); return []; @@ -361,14 +366,17 @@ export class Logger { // This is okay, it just means the checkpoint doesn't exist in either format. return []; } - console.error(`Failed to read or parse checkpoint file ${path}:`, error); + this.debugLogger.error( + `Failed to read or parse checkpoint file ${path}:`, + error, + ); return []; } } async deleteCheckpoint(tag: string): Promise { if (!this.initialized || !this.qwenDir) { - console.error( + this.debugLogger.error( 'Logger not initialized or checkpoint file path not set. Cannot delete checkpoint.', ); return false; @@ -384,7 +392,10 @@ export class Logger { } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code !== 'ENOENT') { - console.error(`Failed to delete checkpoint file ${newPath}:`, error); + this.debugLogger.error( + `Failed to delete checkpoint file ${newPath}:`, + error, + ); throw error; // Rethrow unexpected errors } // It's okay if it doesn't exist. @@ -399,7 +410,10 @@ export class Logger { } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code !== 'ENOENT') { - console.error(`Failed to delete checkpoint file ${oldPath}:`, error); + this.debugLogger.error( + `Failed to delete checkpoint file ${oldPath}:`, + error, + ); throw error; // Rethrow unexpected errors } // It's okay if it doesn't exist. @@ -428,7 +442,7 @@ export class Logger { return false; // It truly doesn't exist in either format. } // A different error occurred. - console.error( + this.debugLogger.error( `Failed to check checkpoint existence for ${ filePath ?? `path for tag "${tag}"` }:`, diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts index e124d92a2..a75b43ca8 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts @@ -11,13 +11,10 @@ import type { RequestContext } from './errorHandler.js'; describe('EnhancedErrorHandler', () => { let errorHandler: EnhancedErrorHandler; - let mockConsoleError: ReturnType; let mockContext: RequestContext; let mockRequest: GenerateContentParameters; beforeEach(() => { - mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockContext = { userPromptId: 'test-prompt-id', model: 'test-model', @@ -63,33 +60,6 @@ describe('EnhancedErrorHandler', () => { }).toThrow(originalError); }); - it('should log error message for non-timeout errors', () => { - const originalError = new Error('Test error'); - - expect(() => { - errorHandler.handle(originalError, mockContext, mockRequest); - }).toThrow(); - - expect(mockConsoleError).toHaveBeenCalledWith( - 'OpenAI API Error:', - 'Test error', - ); - }); - - it('should log streaming error message for streaming requests', () => { - const streamingContext = { ...mockContext, isStreaming: true }; - const originalError = new Error('Test streaming error'); - - expect(() => { - errorHandler.handle(originalError, streamingContext, mockRequest); - }).toThrow(); - - expect(mockConsoleError).toHaveBeenCalledWith( - 'OpenAI API Streaming Error:', - 'Test streaming error', - ); - }); - it('should throw enhanced error message for timeout errors', () => { const timeoutError = new Error('Request timeout'); @@ -98,7 +68,7 @@ describe('EnhancedErrorHandler', () => { }).toThrow(/Request timeout after 5s.*Troubleshooting tips:/s); }); - it('should not log error when suppression is enabled', () => { + it('should use custom suppression function', () => { const suppressLogging = vi.fn(() => true); errorHandler = new EnhancedErrorHandler(suppressLogging); const originalError = new Error('Test error'); @@ -107,7 +77,6 @@ describe('EnhancedErrorHandler', () => { errorHandler.handle(originalError, mockContext, mockRequest); }).toThrow(); - expect(mockConsoleError).not.toHaveBeenCalled(); expect(suppressLogging).toHaveBeenCalledWith(originalError, mockRequest); }); @@ -117,11 +86,6 @@ describe('EnhancedErrorHandler', () => { expect(() => { errorHandler.handle(stringError, mockContext, mockRequest); }).toThrow(stringError); - - expect(mockConsoleError).toHaveBeenCalledWith( - 'OpenAI API Error:', - 'String error message', - ); }); it('should handle null/undefined errors', () => { @@ -378,8 +342,6 @@ describe('EnhancedErrorHandler', () => { expect(() => { errorHandler.handle(emptyError, mockContext, mockRequest); }).toThrow(emptyError); - - expect(mockConsoleError).toHaveBeenCalledWith('OpenAI API Error:', ''); }); it('should handle error with only whitespace message', () => { diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.ts index fe74a87a9..8a7509187 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.ts @@ -5,6 +5,9 @@ */ import type { GenerateContentParameters } from '@google/genai'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('OPENAI_ERROR'); export interface RequestContext { userPromptId: string; @@ -48,7 +51,7 @@ export class EnhancedErrorHandler implements ErrorHandler { const logPrefix = context.isStreaming ? 'OpenAI API Streaming Error:' : 'OpenAI API Error:'; - console.error(logPrefix, errorMessage); + debugLogger.error(logPrefix, errorMessage); } // Provide helpful timeout-specific error message diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index 2009b273c..5c8479bc0 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -15,6 +15,9 @@ import { EnhancedErrorHandler } from './errorHandler.js'; import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; import { isAbortError } from '../../utils/errors.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('OPENAI'); export class OpenAIContentGenerator implements ContentGenerator { protected pipeline: ContentGenerationPipeline; @@ -88,7 +91,7 @@ export class OpenAIContentGenerator implements ContentGenerator { totalTokens: result.totalTokens, }; } catch (error) { - console.warn( + debugLogger.warn( 'Failed to calculate tokens with new tokenizer, falling back to simple method:', error, ); @@ -152,7 +155,7 @@ export class OpenAIContentGenerator implements ContentGenerator { ], }; } catch (error) { - console.error('OpenAI API Embedding Error:', error); + debugLogger.error('OpenAI API Embedding Error:', error); throw new Error( `OpenAI API error: ${error instanceof Error ? error.message : String(error)}`, ); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 7a9799433..176efeb60 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -605,7 +605,6 @@ describe('resolvePathFromEnv helper function', () => { vi.spyOn(os, 'homedir').mockImplementation(() => { throw new Error('Cannot resolve home directory'); }); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = resolvePathFromEnv('~/documents/file.txt'); expect(result).toEqual({ @@ -613,12 +612,6 @@ describe('resolvePathFromEnv helper function', () => { value: null, isDisabled: false, }); - expect(consoleSpy).toHaveBeenCalledWith( - 'Could not resolve home directory for path: ~/documents/file.txt', - expect.any(Error), - ); - - consoleSpy.mockRestore(); }); }); }); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 7f234078b..60c2c56d9 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -12,6 +12,9 @@ import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; import { QWEN_CONFIG_DIR } from '../tools/memoryTool.js'; import type { GenerateContentConfig } from '@google/genai'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('PROMPTS'); export function resolvePathFromEnv(envVar?: string): { isSwitch: boolean; @@ -46,7 +49,7 @@ export function resolvePathFromEnv(envVar?: string): { } } catch (error) { // If os.homedir() fails, we catch the error instead of crashing. - console.warn( + debugLogger.warn( `Could not resolve home directory for path: ${trimmedEnvVar}`, error, ); @@ -777,7 +780,7 @@ function getToolCallExamples(model?: string): string { case 'general': return generalToolCallExamples; default: - console.warn( + debugLogger.warn( `Unknown QWEN_CODE_TOOL_CALL_STYLE value: ${toolCallStyle}. Using model-based detection.`, ); break; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 66703203d..28835bc87 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -23,6 +23,9 @@ import { parse as parseYaml, stringify as stringifyYaml, } from '../utils/yaml-parser.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); export interface ClaudePluginConfig { name: string; @@ -274,7 +277,7 @@ ${systemPrompt} await fs.promises.writeFile(filePath, newContent, 'utf-8'); } catch (error) { - console.warn( + debugLogger.warn( `[Claude Converter] Failed to convert agent file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } @@ -299,7 +302,7 @@ export function convertClaudeToQwenConfig( if (claudeConfig.mcpServers) { if (typeof claudeConfig.mcpServers === 'string') { // TODO: Load from file path - console.warn( + debugLogger.warn( `[Claude Converter] MCP servers path not yet supported: ${claudeConfig.mcpServers}`, ); } else { @@ -309,12 +312,12 @@ export function convertClaudeToQwenConfig( // Warn about unsupported fields if (claudeConfig.hooks) { - console.warn( + debugLogger.warn( `[Claude Converter] Hooks are not yet supported in ${claudeConfig.name}`, ); } if (claudeConfig.outputStyles) { - console.warn( + debugLogger.warn( `[Claude Converter] Output styles are not yet supported in ${claudeConfig.name}`, ); } @@ -414,7 +417,7 @@ export async function convertClaudePluginPackage( MCPServerConfig >; } catch (error) { - console.warn( + debugLogger.warn( `Failed to parse MCP servers file ${mcpServersPath}: ${error instanceof Error ? error.message : String(error)}`, ); } @@ -514,7 +517,7 @@ async function collectResources( : path.join(pluginRoot, resourcePath); if (!fs.existsSync(resolvedPath)) { - console.warn(`Resource path not found: ${resolvedPath}`); + debugLogger.warn(`Resource path not found: ${resolvedPath}`); continue; } @@ -528,7 +531,7 @@ async function collectResources( // If the directory is already named as the destination folder (e.g., 'commands') // and it's at the plugin root level, skip it if (dirName === destFolderName && parentDir === pluginRoot) { - console.log( + debugLogger.debug( `Skipping ${resolvedPath} as it's already in the correct location`, ); continue; @@ -565,7 +568,7 @@ async function collectResources( // e.g., 'commands/test1.md' or 'commands/me/test.md' should be skipped const segments = relativePath.split(path.sep); if (segments.length > 0 && segments[0] === destFolderName) { - console.log( + debugLogger.debug( `Skipping ${resolvedPath} as it's already in ${destFolderName}/`, ); continue; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index db23038dc..be94f9056 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -242,10 +242,6 @@ describe('extension tests', () => { }); it('should skip extensions with invalid JSON and log a warning', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - // Good extension createExtension({ extensionsDir: userExtensionsDir, @@ -265,18 +261,9 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].config.name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining(`Warning: Skipping extension in ${badExtDir}`), - ); - - consoleSpy.mockRestore(); }); it('should skip extensions with missing name and log a warning', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - // Good extension createExtension({ extensionsDir: userExtensionsDir, @@ -296,11 +283,6 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].config.name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining(`Warning: Skipping extension in ${badExtDir}`), - ); - - consoleSpy.mockRestore(); }); it('should filter trust out of mcp servers', async () => { @@ -518,10 +500,6 @@ describe('extension tests', () => { }); it('should log an error for unknown extensions', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -533,10 +511,9 @@ describe('extension tests', () => { }); await manager.refreshCache(); const extensions = manager.getLoadedExtensions(); - manager.validateExtensionOverrides(extensions); - - expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); - consoleSpy.mockRestore(); + expect(() => + manager.validateExtensionOverrides(extensions), + ).not.toThrow(); }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index d229aa0d0..482651de2 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -69,6 +69,9 @@ import { } from '../telemetry/types.js'; import { loadSkillsFromDir } from '../skills/skill-load.js'; import { loadSubagentFromDir } from '../subagents/subagent-manager.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('EXTENSIONS'); // ============================================================================ // Types and Interfaces @@ -238,7 +241,7 @@ async function loadCommandsFromDir(dir: string): Promise { const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'; const isAbortError = error instanceof Error && error.name === 'AbortError'; if (!isEnoent && !isAbortError) { - console.error(`Error loading commands from ${dir}:`, error); + debugLogger.error(`Error loading commands from ${dir}:`, error); } return []; } @@ -347,7 +350,7 @@ export class ExtensionManager { (ext) => ext.config.name.toLowerCase() === name.toLowerCase(), ) ) { - console.error(`Extension not found: ${name}`); + debugLogger.error(`Extension not found: ${name}`); } } } @@ -503,7 +506,7 @@ export class ExtensionManager { ) { return {}; } - console.error('Error reading extension enablement config:', error); + debugLogger.error('Error reading extension enablement config:', error); return {}; } } @@ -658,7 +661,7 @@ export class ExtensionManager { return extension; } catch (e) { - console.error( + debugLogger.warn( `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( e, )}`, @@ -1179,7 +1182,7 @@ export class ExtensionManager { updatedVersion, }; } catch (e) { - console.error( + debugLogger.error( `Error updating extension, rolling back. ${getErrorMessage(e)}`, ); callback(extension.name, ExtensionUpdateState.ERROR); diff --git a/packages/core/src/extension/extensionSettings.ts b/packages/core/src/extension/extensionSettings.ts index e821788ba..e8aa28e26 100644 --- a/packages/core/src/extension/extensionSettings.ts +++ b/packages/core/src/extension/extensionSettings.ts @@ -13,6 +13,9 @@ import type { ExtensionConfig } from './extensionManager.js'; import prompts from 'prompts'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; import { KeychainTokenStorage } from '../mcp/token-storage/keychain-token-storage.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('EXT_SETTINGS'); export interface ExtensionSetting { name: string; @@ -211,7 +214,9 @@ export async function updateSetting( ): Promise { const { name: extensionName, settings } = extensionConfig; if (!settings || settings.length === 0) { - console.log('This extension does not have any settings.'); + debugLogger.debug( + `updateSetting: Extension "${extensionName}" has no settings`, + ); return; } @@ -220,7 +225,9 @@ export async function updateSetting( ); if (!settingToUpdate) { - console.log(`Setting ${settingKey} not found.`); + debugLogger.debug( + `updateSetting: Setting "${settingKey}" not found for extension "${extensionName}"`, + ); return; } diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index 5c0da4dd0..8d793963e 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -15,6 +15,9 @@ import type { ExtensionConfig } from './extensionManager.js'; import type { ExtensionSetting } from './extensionSettings.js'; import { ExtensionStorage } from './storage.js'; import { convertTomlToMarkdown } from '../utils/toml-to-markdown-converter.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('GEMINI_CONVERTER'); export interface GeminiExtensionConfig { name: string; @@ -165,7 +168,7 @@ async function convertCommandsDirectory(commandsDir: string): Promise { // Delete original TOML file fs.unlinkSync(tomlPath); } catch (error) { - console.warn( + debugLogger.warn( `Warning: Failed to convert command file ${relativeFile}: ${error instanceof Error ? error.message : String(error)}`, ); // Continue with other files even if one fails diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index f5e45a684..9e1d46ed4 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -13,6 +13,7 @@ import * as path from 'node:path'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; import * as tar from 'tar'; import extract from 'extract-zip'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { ExtensionUpdateState, type Extension, @@ -21,6 +22,8 @@ import { } from './extensionManager.js'; import type { ExtensionInstallMetadata } from '../config/config.js'; +const debugLogger = createDebugLogger('EXT_GITHUB'); + interface GithubReleaseData { assets: Asset[]; tag_name: string; @@ -145,14 +148,14 @@ export async function checkForExtensionUpdate( extensionDir: installMetadata.source, }); } catch (e) { - console.error( + debugLogger.error( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}. Error: ${getErrorMessage(e)}`, ); return ExtensionUpdateState.NOT_UPDATABLE; } if (!latestConfig) { - console.error( + debugLogger.error( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, ); return ExtensionUpdateState.NOT_UPDATABLE; @@ -174,12 +177,14 @@ export async function checkForExtensionUpdate( const git = simpleGit(extension.path); const remotes = await git.getRemotes(true); if (remotes.length === 0) { - console.error('No git remotes found.'); + debugLogger.error('No git remotes found.'); return ExtensionUpdateState.ERROR; } const remoteUrl = remotes[0].refs.fetch; if (!remoteUrl) { - console.error(`No fetch URL found for git remote ${remotes[0].name}.`); + debugLogger.error( + `No fetch URL found for git remote ${remotes[0].name}.`, + ); return ExtensionUpdateState.ERROR; } @@ -189,7 +194,7 @@ export async function checkForExtensionUpdate( const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { - console.error(`Git ref ${refToCheck} not found.`); + debugLogger.error(`Git ref ${refToCheck} not found.`); return ExtensionUpdateState.ERROR; } @@ -197,7 +202,7 @@ export async function checkForExtensionUpdate( const localHash = await git.revparse(['HEAD']); if (!remoteHash) { - console.error( + debugLogger.error( `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, ); return ExtensionUpdateState.ERROR; @@ -209,7 +214,7 @@ export async function checkForExtensionUpdate( } else { const { source, releaseTag } = installMetadata; if (!source) { - console.error(`No "source" provided for extension.`); + debugLogger.error('No "source" provided for extension.'); return ExtensionUpdateState.ERROR; } const { owner, repo } = parseGitHubRepoForReleases(source); @@ -225,7 +230,7 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.UP_TO_DATE; } } catch (error) { - console.error( + debugLogger.error( `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, ); return ExtensionUpdateState.ERROR; diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b216506f7..d839004ad 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -25,13 +25,9 @@ import * as path from 'node:path'; import { EnvHttpProxyAgent } from 'undici'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; -const logger = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - debug: (...args: any[]) => console.debug('[DEBUG] [IDEClient]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: (...args: any[]) => console.error('[ERROR] [IDEClient]', ...args), -}; +const debugLogger = createDebugLogger('IDE'); export type DiffUpdateResult = | { @@ -261,7 +257,7 @@ export class IdeClient { ); const errorMessage = textPart?.text ?? `Tool 'openDiff' reported an error.`; - logger.debug( + debugLogger.debug( `Request for openDiff ${filePath} failed with isError:`, errorMessage, ); @@ -270,7 +266,7 @@ export class IdeClient { } }) .catch((err) => { - logger.debug(`Request for openDiff ${filePath} failed:`, err); + debugLogger.debug(`Request for openDiff ${filePath} failed:`, err); this.diffResponses.delete(filePath); reject(err); }); @@ -342,7 +338,7 @@ export class IdeClient { ); const errorMessage = textPart?.text ?? `Tool 'closeDiff' reported an error.`; - logger.debug( + debugLogger.debug( `Request for closeDiff ${filePath} failed with isError:`, errorMessage, ); @@ -361,14 +357,14 @@ export class IdeClient { return undefined; } } catch (_e) { - logger.debug( + debugLogger.debug( `Invalid JSON in closeDiff response for ${filePath}:`, textPart.text, ); } } } catch (err) { - logger.debug(`Request for closeDiff ${filePath} failed:`, err); + debugLogger.debug(`Request for closeDiff ${filePath} failed:`, err); } return undefined; } @@ -434,7 +430,7 @@ export class IdeClient { return; } try { - logger.debug('Discovering tools from IDE...'); + debugLogger.debug('Discovering tools from IDE...'); const response = await this.client.request( { method: 'tools/list', params: {} }, ListToolsResultSchema, @@ -444,11 +440,11 @@ export class IdeClient { this.availableTools = response.tools.map((tool) => tool.name); if (this.availableTools.length > 0) { - logger.debug( + debugLogger.debug( `Discovered ${this.availableTools.length} tools from IDE: ${this.availableTools.join(', ')}`, ); } else { - logger.debug( + debugLogger.debug( 'IDE supports tool discovery, but no tools are available.', ); } @@ -459,9 +455,9 @@ export class IdeClient { error instanceof Error && !error.message?.includes('Method not found') ) { - logger.error(`Error discovering tools from IDE: ${error.message}`); + debugLogger.error(`Error discovering tools from IDE: ${error.message}`); } else { - logger.debug('IDE does not support tool discovery.'); + debugLogger.debug('IDE does not support tool discovery.'); } this.availableTools = []; } @@ -485,11 +481,11 @@ export class IdeClient { } if (details) { if (logToConsole) { - logger.error(details); + debugLogger.error(details); } else { // We only want to log disconnect messages to debug // if they are not already being logged to the console. - logger.debug(details); + debugLogger.debug(details); } } } @@ -557,12 +553,15 @@ export class IdeClient { if (Array.isArray(parsedArgs)) { args = parsedArgs; } else { - logger.error( + debugLogger.error( 'QWEN_CODE_IDE_SERVER_STDIO_ARGS must be a JSON array string.', ); } } catch (e) { - logger.error('Failed to parse QWEN_CODE_IDE_SERVER_STDIO_ARGS:', e); + debugLogger.error( + 'Failed to parse QWEN_CODE_IDE_SERVER_STDIO_ARGS:', + e, + ); } } @@ -640,7 +639,7 @@ export class IdeClient { fileRegex.test(file), ); } catch (e) { - logger.debug('Failed to read IDE connection directory:', e); + debugLogger.debug('Failed to read IDE connection directory:', e); return []; } @@ -654,13 +653,13 @@ export class IdeClient { const parsed = JSON.parse(content); return { file, mtimeMs: stat.mtimeMs, parsed }; } catch (e) { - logger.debug('Failed to parse JSON from lock file: ', e); + debugLogger.debug('Failed to parse JSON from lock file: ', e); return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; } } catch (e) { // If we can't stat/read the file, treat it as very old so it doesn't // win ties, and skip parsing by returning undefined content. - logger.debug('Failed to read/stat IDE lock file:', e); + debugLogger.debug('Failed to read/stat IDE lock file:', e); return { file, mtimeMs: -Infinity, parsed: undefined }; } }), @@ -742,7 +741,7 @@ export class IdeClient { resolver({ status: 'accepted', content }); this.diffResponses.delete(filePath); } else { - logger.debug(`No resolver found for ${filePath}`); + debugLogger.debug(`No resolver found for ${filePath}`); } }, ); @@ -756,7 +755,7 @@ export class IdeClient { resolver({ status: 'rejected', content: undefined }); this.diffResponses.delete(filePath); } else { - logger.debug(`No resolver found for ${filePath}`); + debugLogger.debug(`No resolver found for ${filePath}`); } }, ); @@ -772,7 +771,7 @@ export class IdeClient { resolver({ status: 'rejected', content: undefined }); this.diffResponses.delete(filePath); } else { - logger.debug(`No resolver found for ${filePath}`); + debugLogger.debug(`No resolver found for ${filePath}`); } }, ); @@ -781,7 +780,7 @@ export class IdeClient { private async establishHttpConnection(port: string): Promise { let transport: StreamableHTTPClientTransport | undefined; try { - logger.debug('Attempting to connect to IDE via HTTP SSE'); + debugLogger.debug('Attempting to connect to IDE via HTTP SSE'); this.client = new Client({ name: 'streamable-http-client', // TODO(#3487): use the CLI version here. @@ -812,7 +811,7 @@ export class IdeClient { try { await transport.close(); } catch (closeError) { - logger.debug('Failed to close transport:', closeError); + debugLogger.debug('Failed to close transport:', closeError); } } return false; @@ -825,7 +824,7 @@ export class IdeClient { }: StdioConfig): Promise { let transport: StdioClientTransport | undefined; try { - logger.debug('Attempting to connect to IDE via stdio'); + debugLogger.debug('Attempting to connect to IDE via stdio'); this.client = new Client({ name: 'stdio-client', // TODO(#3487): use the CLI version here. @@ -846,7 +845,7 @@ export class IdeClient { try { await transport.close(); } catch (closeError) { - logger.debug('Failed to close transport:', closeError); + debugLogger.debug('Failed to close transport:', closeError); } } return false; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd319d674..c76fd2f8d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,7 +68,83 @@ export * from './tools/tool-names.js'; // Tools // ============================================================================ -// Base tool system +// Export utilities +export * from './utils/paths.js'; +export * from './utils/schemaValidator.js'; +export * from './utils/errors.js'; +export * from './utils/debugLogger.js'; +export * from './utils/getFolderStructure.js'; +export * from './utils/memoryDiscovery.js'; +export * from './utils/gitIgnoreParser.js'; +export * from './utils/gitUtils.js'; +export * from './utils/editor.js'; +export * from './utils/quotaErrorDetection.js'; +export * from './utils/fileUtils.js'; +export * from './utils/retry.js'; +export * from './utils/shell-utils.js'; +export * from './utils/tool-utils.js'; +export * from './utils/terminalSerializer.js'; +export * from './utils/systemEncoding.js'; +export * from './utils/textUtils.js'; +export * from './utils/formatters.js'; +export * from './utils/generateContentResponseUtilities.js'; +export * from './utils/ripgrepUtils.js'; +export * from './utils/filesearch/fileSearch.js'; +export * from './utils/errorParsing.js'; +export * from './utils/workspaceContext.js'; +export * from './utils/ignorePatterns.js'; +export * from './utils/partUtils.js'; +export * from './utils/subagentGenerator.js'; +export * from './utils/projectSummary.js'; +export * from './utils/promptIdContext.js'; +export * from './utils/thoughtUtils.js'; +export * from './utils/toml-to-markdown-converter.js'; +export * from './utils/yaml-parser.js'; + +// Config resolution utilities +export * from './utils/configResolver.js'; + +// Export services +export * from './services/fileDiscoveryService.js'; +export * from './services/gitService.js'; +export * from './services/chatRecordingService.js'; +export * from './services/sessionService.js'; +export * from './services/fileSystemService.js'; + +// Export IDE specific logic +export * from './ide/ide-client.js'; +export * from './ide/ideContext.js'; +export * from './ide/ide-installer.js'; +export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; +export * from './ide/constants.js'; +export * from './ide/types.js'; + +// Export Shell Execution Service +export * from './services/shellExecutionService.js'; + +// Export base tool definitions +export * from './tools/tools.js'; +export * from './tools/tool-error.js'; +export * from './tools/tool-registry.js'; + +// Export subagents (Phase 1) +export * from './subagents/index.js'; + +// Export skills +export * from './skills/index.js'; + +// Export extension +export * from './extension/index.js'; + +// Export prompt logic +export * from './prompts/mcp-prompts.js'; + +// Export specific tool logic +export * from './tools/read-file.js'; +export * from './tools/ls.js'; +export * from './tools/grep.js'; +export * from './tools/ripGrep.js'; +export * from './tools/glob.js'; export * from './tools/edit.js'; export * from './tools/exitPlanMode.js'; export * from './tools/glob.js'; @@ -105,21 +181,9 @@ export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; // ============================================================================ -// IDE & LSP Support +// LSP Support // ============================================================================ -// IDE integration -export * from './ide/constants.js'; -export { - IDE_DEFINITIONS, - type IdeInfo, - detectIdeFromEnv, -} from './ide/detect-ide.js'; -export * from './ide/ide-client.js'; -export * from './ide/ide-installer.js'; -export * from './ide/ideContext.js'; -export * from './ide/types.js'; - // LSP support export * from './lsp/constants.js'; export * from './lsp/LspConfigLoader.js'; diff --git a/packages/core/src/lsp/LspConfigLoader.ts b/packages/core/src/lsp/LspConfigLoader.ts index b091a957a..61ffad8b5 100644 --- a/packages/core/src/lsp/LspConfigLoader.ts +++ b/packages/core/src/lsp/LspConfigLoader.ts @@ -17,6 +17,9 @@ import type { LspServerConfig, LspSocketOptions, } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LSP'); export class LspConfigLoader { constructor(private readonly workspaceRoot: string) {} @@ -36,7 +39,7 @@ export class LspConfigLoader { const data = JSON.parse(configContent); return this.parseConfigSource(data, lspConfigPath); } catch (error) { - console.warn('Failed to load user .lsp.json config:', error); + debugLogger.warn('Failed to load user .lsp.json config:', error); return []; } } @@ -62,7 +65,9 @@ export class LspConfigLoader { lspServers, ); if (!fs.existsSync(configPath)) { - console.warn(`LSP config not found for ${originBase}: ${configPath}`); + debugLogger.warn( + `LSP config not found for ${originBase}: ${configPath}`, + ); continue; } @@ -77,7 +82,7 @@ export class LspConfigLoader { ), ); } catch (error) { - console.warn( + debugLogger.warn( `Failed to load extension LSP config from ${configPath}:`, error, ); @@ -91,7 +96,7 @@ export class LspConfigLoader { ...this.parseConfigSource(hydrated, `${originBase} (lspServers)`), ); } else { - console.warn( + debugLogger.warn( `LSP config for ${originBase} must be an object or a JSON file path.`, ); } @@ -316,12 +321,14 @@ export class LspConfigLoader { const socket = this.normalizeSocketOptions(spec); if (transport === 'stdio' && !command) { - console.warn(`LSP config error in ${origin}: ${name} missing command`); + debugLogger.warn( + `LSP config error in ${origin}: ${name} missing command`, + ); return null; } if (transport !== 'stdio' && !socket) { - console.warn( + debugLogger.warn( `LSP config error in ${origin}: ${name} missing socket info`, ); return null; @@ -485,7 +492,7 @@ export class LspConfigLoader { return resolved; } - console.warn( + debugLogger.warn( `LSP workspaceFolder must be within ${this.workspaceRoot}; using workspace root instead.`, ); return this.workspaceRoot; diff --git a/packages/core/src/lsp/LspConnectionFactory.ts b/packages/core/src/lsp/LspConnectionFactory.ts index dfcecd86d..e6f59718a 100644 --- a/packages/core/src/lsp/LspConnectionFactory.ts +++ b/packages/core/src/lsp/LspConnectionFactory.ts @@ -8,6 +8,9 @@ import * as cp from 'node:child_process'; import * as net from 'node:net'; import { DEFAULT_LSP_REQUEST_TIMEOUT_MS } from './constants.js'; import type { JsonRpcMessage } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LSP'); interface PendingRequest { resolve: (value: unknown) => void; @@ -375,7 +378,7 @@ export class LspConnectionFactory { try { await lspConnection.connection.shutdown(); } catch (e) { - console.warn('LSP shutdown failed:', e); + debugLogger.warn('LSP shutdown failed:', e); } finally { lspConnection.connection.end(); } diff --git a/packages/core/src/lsp/LspServerManager.ts b/packages/core/src/lsp/LspServerManager.ts index 74b25f779..ce5898062 100644 --- a/packages/core/src/lsp/LspServerManager.ts +++ b/packages/core/src/lsp/LspServerManager.ts @@ -28,6 +28,9 @@ import type { LspServerStatus, LspSocketOptions, } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LSP'); export interface LspServerManagerOptions { requireTrustedWorkspace: boolean; @@ -137,7 +140,7 @@ export class LspServerManager { handle.warmedUp = true; } catch (error) { // Do not set warmedUp to true on failure, allowing retry - console.warn('TypeScript server warm-up failed:', error); + debugLogger.warn('TypeScript server warm-up failed:', error); } } @@ -197,7 +200,7 @@ export class LspServerManager { (this.requireTrustedWorkspace || handle.config.trustRequired) && !workspaceTrusted ) { - console.log( + debugLogger.warn( `LSP server ${name} requires trusted workspace, skipping startup`, ); handle.status = 'FAILED'; @@ -211,7 +214,7 @@ export class LspServerManager { workspaceTrusted, ); if (!consent) { - console.log(`User declined to start LSP server ${name}`); + debugLogger.info(`User declined to start LSP server ${name}`); handle.status = 'FAILED'; return; } @@ -226,7 +229,7 @@ export class LspServerManager { commandCwd, )) ) { - console.warn( + debugLogger.warn( `LSP server ${name} command not found: ${handle.config.command}`, ); handle.status = 'FAILED'; @@ -237,7 +240,7 @@ export class LspServerManager { if ( !this.isPathSafe(handle.config.command, this.workspaceRoot, commandCwd) ) { - console.warn( + debugLogger.warn( `LSP server ${name} command path is unsafe: ${handle.config.command}`, ); handle.status = 'FAILED'; @@ -260,11 +263,11 @@ export class LspServerManager { handle.status = 'READY'; this.attachRestartHandler(name, handle); - console.log(`LSP server ${name} started successfully`); + debugLogger.info(`LSP server ${name} started successfully`); } catch (error) { handle.status = 'FAILED'; handle.error = error as Error; - console.error(`LSP server ${name} failed to start:`, error); + debugLogger.error(`LSP server ${name} failed to start:`, error); } } @@ -281,7 +284,7 @@ export class LspServerManager { try { await this.shutdownConnection(handle); } catch (error) { - console.error(`Error closing LSP server ${name}:`, error); + debugLogger.error(`Error closing LSP server ${name}:`, error); } } else if (handle.process && handle.process.exitCode === null) { handle.process.kill(); @@ -333,14 +336,14 @@ export class LspServerManager { } const attempts = handle.restartAttempts ?? 0; if (attempts >= maxRestarts) { - console.warn( + debugLogger.warn( `LSP server ${name} reached max restart attempts (${maxRestarts}), stopping restarts`, ); handle.status = 'FAILED'; return; } handle.restartAttempts = attempts + 1; - console.warn( + debugLogger.warn( `LSP server ${name} exited (code ${code ?? 'unknown'}), restarting (${handle.restartAttempts}/${maxRestarts})`, ); this.resetHandle(handle); @@ -583,7 +586,7 @@ export class LspServerManager { }); } } catch (error) { - console.warn('TypeScript LSP warm-up failed:', error); + debugLogger.warn('TypeScript LSP warm-up failed:', error); } } } @@ -667,13 +670,13 @@ export class LspServerManager { } if (this.requireTrustedWorkspace || serverConfig.trustRequired) { - console.log( + debugLogger.warn( `Workspace not trusted, skipping LSP server ${serverName} (${serverConfig.command ?? serverConfig.transport})`, ); return false; } - console.log( + debugLogger.info( `Untrusted workspace, but LSP server ${serverName} has trustRequired=false, attempting cautious startup`, ); return true; diff --git a/packages/core/src/lsp/NativeLspService.ts b/packages/core/src/lsp/NativeLspService.ts index 23447ad70..df969cf2a 100644 --- a/packages/core/src/lsp/NativeLspService.ts +++ b/packages/core/src/lsp/NativeLspService.ts @@ -40,6 +40,9 @@ import type { import * as path from 'path'; import { fileURLToPath } from 'url'; import * as fs from 'node:fs'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LSP'); export class NativeLspService { private config: CoreConfig; @@ -93,7 +96,9 @@ export class NativeLspService { // Check if workspace is trusted if (this.requireTrustedWorkspace && !workspaceTrusted) { - console.log('Workspace is not trusted, skipping LSP server discovery'); + debugLogger.warn( + 'Workspace is not trusted, skipping LSP server discovery', + ); return; } @@ -217,7 +222,10 @@ export class NativeLspService { } } } catch (error) { - console.warn(`LSP workspace/symbol failed for ${serverName}:`, error); + debugLogger.warn( + `LSP workspace/symbol failed for ${serverName}:`, + error, + ); } } @@ -263,7 +271,10 @@ export class NativeLspService { return definitions.slice(0, limit); } } catch (error) { - console.warn(`LSP textDocument/definition failed for ${name}:`, error); + debugLogger.warn( + `LSP textDocument/definition failed for ${name}:`, + error, + ); } } @@ -309,7 +320,10 @@ export class NativeLspService { return refs.slice(0, limit); } } catch (error) { - console.warn(`LSP textDocument/references failed for ${name}:`, error); + debugLogger.warn( + `LSP textDocument/references failed for ${name}:`, + error, + ); } } @@ -337,7 +351,7 @@ export class NativeLspService { return normalized; } } catch (error) { - console.warn(`LSP textDocument/hover failed for ${name}:`, error); + debugLogger.warn(`LSP textDocument/hover failed for ${name}:`, error); } } @@ -397,7 +411,7 @@ export class NativeLspService { return symbols.slice(0, limit); } } catch (error) { - console.warn( + debugLogger.warn( `LSP textDocument/documentSymbol failed for ${name}:`, error, ); @@ -449,7 +463,7 @@ export class NativeLspService { return implementations.slice(0, limit); } } catch (error) { - console.warn( + debugLogger.warn( `LSP textDocument/implementation failed for ${name}:`, error, ); @@ -501,7 +515,7 @@ export class NativeLspService { return items.slice(0, limit); } } catch (error) { - console.warn( + debugLogger.warn( `LSP textDocument/prepareCallHierarchy failed for ${name}:`, error, ); @@ -548,7 +562,7 @@ export class NativeLspService { return calls.slice(0, limit); } } catch (error) { - console.warn( + debugLogger.warn( `LSP callHierarchy/incomingCalls failed for ${name}:`, error, ); @@ -595,7 +609,7 @@ export class NativeLspService { return calls.slice(0, limit); } } catch (error) { - console.warn( + debugLogger.warn( `LSP callHierarchy/outgoingCalls failed for ${name}:`, error, ); @@ -645,7 +659,10 @@ export class NativeLspService { } catch (error) { // Fall back to cached diagnostics from publishDiagnostics notifications // This is handled by the notification handler if implemented - console.warn(`LSP textDocument/diagnostic failed for ${name}:`, error); + debugLogger.warn( + `LSP textDocument/diagnostic failed for ${name}:`, + error, + ); } } @@ -693,7 +710,7 @@ export class NativeLspService { } } } catch (error) { - console.warn(`LSP workspace/diagnostic failed for ${name}:`, error); + debugLogger.warn(`LSP workspace/diagnostic failed for ${name}:`, error); } if (results.length >= limit) { @@ -760,7 +777,10 @@ export class NativeLspService { return actions.slice(0, limit); } } catch (error) { - console.warn(`LSP textDocument/codeAction failed for ${name}:`, error); + debugLogger.warn( + `LSP textDocument/codeAction failed for ${name}:`, + error, + ); } } @@ -794,7 +814,7 @@ export class NativeLspService { return true; } catch (error) { - console.error('Failed to apply workspace edit:', error); + debugLogger.error('Failed to apply workspace edit:', error); return false; } } diff --git a/packages/core/src/mcp/google-auth-provider.ts b/packages/core/src/mcp/google-auth-provider.ts index 8c858714c..7527bb3cd 100644 --- a/packages/core/src/mcp/google-auth-provider.ts +++ b/packages/core/src/mcp/google-auth-provider.ts @@ -14,6 +14,9 @@ import type { import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; import { MCP_OAUTH_CLIENT_NAME } from './constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MCP_GOOGLE_AUTH'); const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/]; @@ -70,7 +73,7 @@ export class GoogleCredentialProvider implements OAuthClientProvider { const accessTokenResponse = await client.getAccessToken(); if (!accessTokenResponse.token) { - console.error('Failed to get access token from Google ADC'); + debugLogger.error('Failed to get access token from Google ADC'); return undefined; } diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index e23c25d07..caabe5d92 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -6,6 +6,17 @@ import { vi } from 'vitest'; +// Mock debugLogger +const mockDebugLogger = vi.hoisted(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: vi.fn(() => mockDebugLogger), +})); + // Mock dependencies AT THE TOP const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); vi.mock('../utils/secure-browser-launcher.js', () => ({ @@ -938,7 +949,7 @@ describe('MCPOAuthProvider', () => { expect(tokenStorage.deleteCredentials).toHaveBeenCalledWith( 'test-server', ); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to refresh token'), ); }); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 2b657f352..20723414e 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -13,6 +13,7 @@ import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; import { OAuthUtils } from './oauth-utils.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { MCP_OAUTH_CLIENT_NAME, OAUTH_REDIRECT_PORT, @@ -21,6 +22,8 @@ import { export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; +const debugLogger = createDebugLogger('MCP_OAUTH'); + /** * OAuth configuration for an MCP server. */ @@ -265,7 +268,7 @@ export class MCPOAuthProvider { server.on('error', reject); server.listen(OAUTH_REDIRECT_PORT, () => { - console.log( + debugLogger.debug( `OAuth callback server listening on port ${OAUTH_REDIRECT_PORT}`, ); }); @@ -324,7 +327,7 @@ export class MCPOAuthProvider { OAuthUtils.buildResourceParameter(mcpServerUrl), ); } catch (error) { - console.warn( + debugLogger.warn( `Could not add resource parameter: ${getErrorMessage(error)}`, ); } @@ -382,7 +385,7 @@ export class MCPOAuthProvider { OAuthUtils.buildResourceParameter(resourceUrl), ); } catch (error) { - console.warn( + debugLogger.warn( `Could not add resource parameter: ${getErrorMessage(error)}`, ); } @@ -424,7 +427,7 @@ export class MCPOAuthProvider { !contentType.includes('application/json') && !contentType.includes('application/x-www-form-urlencoded') ) { - console.warn( + debugLogger.warn( `Token endpoint returned unexpected content-type: ${contentType}. ` + `Expected application/json or application/x-www-form-urlencoded. ` + `Will attempt to parse response.`, @@ -504,7 +507,7 @@ export class MCPOAuthProvider { OAuthUtils.buildResourceParameter(mcpServerUrl), ); } catch (error) { - console.warn( + debugLogger.warn( `Could not add resource parameter: ${getErrorMessage(error)}`, ); } @@ -546,7 +549,7 @@ export class MCPOAuthProvider { !contentType.includes('application/json') && !contentType.includes('application/x-www-form-urlencoded') ) { - console.warn( + debugLogger.warn( `Token refresh endpoint returned unexpected content-type: ${contentType}. ` + `Expected application/json or application/x-www-form-urlencoded. ` + `Will attempt to parse response.`, @@ -599,18 +602,18 @@ export class MCPOAuthProvider { mcpServerUrl?: string, events?: EventEmitter, ): Promise { - // Helper function to display messages through handler or fallback to console.log + // Helper function to display messages through handler or fallback to debugLogger const displayMessage = (message: string) => { if (events) { events.emit(OAUTH_DISPLAY_MESSAGE_EVENT, message); } else { - console.log(message); + debugLogger.info(message); } }; // If no authorization URL is provided, try to discover OAuth configuration if (!config.authorizationUrl && mcpServerUrl) { - console.debug(`Starting OAuth for MCP server "${serverName}"… + debugLogger.debug(`Starting OAuth for MCP server "${serverName}"… ✓ No authorization URL; using OAuth discovery`); // First check if the server requires authentication via WWW-Authenticate header @@ -647,7 +650,7 @@ export class MCPOAuthProvider { } } } catch (error) { - console.debug( + debugLogger.debug( `Failed to check endpoint for authentication requirements: ${getErrorMessage(error)}`, ); } @@ -692,7 +695,7 @@ export class MCPOAuthProvider { const authUrl = new URL(config.authorizationUrl); const serverUrl = `${authUrl.protocol}//${authUrl.host}`; - console.debug('→ Attempting dynamic client registration...'); + debugLogger.debug('→ Attempting dynamic client registration...'); // Get the authorization server metadata for registration const authServerMetadata = @@ -718,7 +721,7 @@ export class MCPOAuthProvider { config.clientSecret = clientRegistration.client_secret; } - console.debug('✓ Dynamic client registration successful'); + debugLogger.debug('✓ Dynamic client registration successful'); } else { throw new Error( 'No client ID provided and dynamic registration not supported', @@ -758,16 +761,17 @@ ${authUrl} try { await openBrowserSecurely(authUrl); } catch (error) { - console.warn( - 'Failed to open browser automatically:', - getErrorMessage(error), + debugLogger.warn( + `Failed to open browser automatically: ${getErrorMessage(error)}`, ); } // Wait for callback const { code } = await callbackPromise; - console.debug('✓ Authorization code received, exchanging for tokens...'); + debugLogger.debug( + '✓ Authorization code received, exchanging for tokens...', + ); // Exchange code for tokens const tokenResponse = await this.exchangeCodeForToken( @@ -802,7 +806,7 @@ ${authUrl} config.tokenUrl, mcpServerUrl, ); - console.debug('✓ Authentication successful! Token saved.'); + debugLogger.debug('✓ Authentication successful! Token saved.'); // Verify token was saved const savedToken = await this.tokenStorage.getCredentials(serverName); @@ -813,16 +817,16 @@ ${authUrl} .update(savedToken.token.accessToken) .digest('hex') .slice(0, 8); - console.debug( + debugLogger.debug( `✓ Token verification successful (fingerprint: ${tokenFingerprint})`, ); } else { - console.error( + debugLogger.error( 'Token verification failed: token not found or invalid after save', ); } } catch (saveError) { - console.error(`Failed to save token: ${getErrorMessage(saveError)}`); + debugLogger.error(`Failed to save token: ${getErrorMessage(saveError)}`); throw saveError; } @@ -840,29 +844,31 @@ ${authUrl} serverName: string, config: MCPOAuthConfig, ): Promise { - console.debug(`Getting valid token for server: ${serverName}`); + debugLogger.debug(`Getting valid token for server: ${serverName}`); const credentials = await this.tokenStorage.getCredentials(serverName); if (!credentials) { - console.debug(`No credentials found for server: ${serverName}`); + debugLogger.debug(`No credentials found for server: ${serverName}`); return null; } const { token } = credentials; - console.debug( + debugLogger.debug( `Found token for server: ${serverName}, expired: ${this.tokenStorage.isTokenExpired(token)}`, ); // Check if token is expired if (!this.tokenStorage.isTokenExpired(token)) { - console.debug(`Returning valid token for server: ${serverName}`); + debugLogger.debug(`Returning valid token for server: ${serverName}`); return token.accessToken; } // Try to refresh if we have a refresh token if (token.refreshToken && config.clientId && credentials.tokenUrl) { try { - console.log(`Refreshing expired token for MCP server: ${serverName}`); + debugLogger.info( + `Refreshing expired token for MCP server: ${serverName}`, + ); const newTokenResponse = await this.refreshAccessToken( config, @@ -893,7 +899,7 @@ ${authUrl} return newToken.accessToken; } catch (error) { - console.error(`Failed to refresh token: ${getErrorMessage(error)}`); + debugLogger.error(`Failed to refresh token: ${getErrorMessage(error)}`); // Remove invalid token await this.tokenStorage.deleteCredentials(serverName); } diff --git a/packages/core/src/mcp/oauth-token-storage.test.ts b/packages/core/src/mcp/oauth-token-storage.test.ts index c18f3d122..3eb34b522 100644 --- a/packages/core/src/mcp/oauth-token-storage.test.ts +++ b/packages/core/src/mcp/oauth-token-storage.test.ts @@ -12,6 +12,17 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from './token-storage/index.js'; import type { OAuthCredentials, OAuthToken } from './token-storage/types.js'; import { QWEN_DIR } from '../utils/paths.js'; +// Mock debugLogger +const mockDebugLogger = vi.hoisted(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: vi.fn(() => mockDebugLogger), +})); + // Mock dependencies vi.mock('node:fs', () => ({ promises: { @@ -72,7 +83,6 @@ describe('MCPOAuthTokenStorage', () => { tokenStorage = new MCPOAuthTokenStorage(); vi.clearAllMocks(); - vi.spyOn(console, 'error'); }); afterEach(() => { @@ -87,7 +97,7 @@ describe('MCPOAuthTokenStorage', () => { const tokens = await tokenStorage.getAllCredentials(); expect(tokens.size).toBe(0); - expect(console.error).not.toHaveBeenCalled(); + expect(mockDebugLogger.error).not.toHaveBeenCalled(); }); it('should load tokens from file successfully', async () => { @@ -110,7 +120,7 @@ describe('MCPOAuthTokenStorage', () => { const tokens = await tokenStorage.getAllCredentials(); expect(tokens.size).toBe(0); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to load MCP OAuth tokens'), ); }); @@ -122,7 +132,7 @@ describe('MCPOAuthTokenStorage', () => { const tokens = await tokenStorage.getAllCredentials(); expect(tokens.size).toBe(0); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to load MCP OAuth tokens'), ); }); @@ -188,7 +198,7 @@ describe('MCPOAuthTokenStorage', () => { tokenStorage.saveToken('test-server', mockToken), ).rejects.toThrow('Disk full'); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to save MCP OAuth token'), ); }); @@ -281,7 +291,7 @@ describe('MCPOAuthTokenStorage', () => { await tokenStorage.deleteCredentials('test-server'); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to remove MCP OAuth token'), ); }); @@ -347,7 +357,7 @@ describe('MCPOAuthTokenStorage', () => { await tokenStorage.clearAll(); - expect(console.error).not.toHaveBeenCalled(); + expect(mockDebugLogger.error).not.toHaveBeenCalled(); }); it('should handle other file errors gracefully', async () => { @@ -355,7 +365,7 @@ describe('MCPOAuthTokenStorage', () => { await tokenStorage.clearAll(); - expect(console.error).toHaveBeenCalledWith( + expect(mockDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to clear MCP OAuth tokens'), ); }); @@ -368,7 +378,6 @@ describe('MCPOAuthTokenStorage', () => { tokenStorage = new MCPOAuthTokenStorage(); vi.clearAllMocks(); - vi.spyOn(console, 'error'); }); afterEach(() => { diff --git a/packages/core/src/mcp/oauth-token-storage.ts b/packages/core/src/mcp/oauth-token-storage.ts index d9d98ff41..e1bc53105 100644 --- a/packages/core/src/mcp/oauth-token-storage.ts +++ b/packages/core/src/mcp/oauth-token-storage.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import { Storage } from '../config/storage.js'; import { getErrorMessage } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { OAuthToken, OAuthCredentials, @@ -19,6 +20,8 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR, } from './token-storage/index.js'; +const debugLogger = createDebugLogger('MCP_OAUTH'); + /** * Class for managing MCP OAuth token storage and retrieval. */ @@ -68,7 +71,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { } catch (error) { // File doesn't exist or is invalid, return empty map if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error( + debugLogger.error( `Failed to load MCP OAuth tokens: ${getErrorMessage(error)}`, ); } @@ -102,7 +105,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { { mode: 0o600 }, // Restrict file permissions ); } catch (error) { - console.error( + debugLogger.error( `Failed to save MCP OAuth token: ${getErrorMessage(error)}`, ); throw error; @@ -181,7 +184,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { }); } } catch (error) { - console.error( + debugLogger.error( `Failed to remove MCP OAuth token: ${getErrorMessage(error)}`, ); } @@ -216,7 +219,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { await fs.unlink(tokenFile); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error( + debugLogger.error( `Failed to clear MCP OAuth tokens: ${getErrorMessage(error)}`, ); } diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 47ef1d366..dc65e0665 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -6,6 +6,9 @@ import type { MCPOAuthConfig } from './oauth-provider.js'; import { getErrorMessage } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MCP_OAUTH'); /** * OAuth authorization server metadata as per RFC 8414. @@ -94,7 +97,7 @@ export class OAuthUtils { } return (await response.json()) as OAuthProtectedResourceMetadata; } catch (error) { - console.debug( + debugLogger.debug( `Failed to fetch protected resource metadata from ${resourceMetadataUrl}: ${getErrorMessage(error)}`, ); return null; @@ -117,7 +120,7 @@ export class OAuthUtils { } return (await response.json()) as OAuthAuthorizationServerMetadata; } catch (error) { - console.debug( + debugLogger.debug( `Failed to fetch authorization server metadata from ${authServerMetadataUrl}: ${getErrorMessage(error)}`, ); return null; @@ -205,7 +208,7 @@ export class OAuthUtils { } } - console.debug( + debugLogger.debug( `Metadata discovery failed for authorization server ${authServerUrl}`, ); return null; @@ -249,9 +252,8 @@ export class OAuthUtils { if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); if (authServerMetadata.registration_endpoint) { - console.log( - 'Dynamic client registration is supported at:', - authServerMetadata.registration_endpoint, + debugLogger.debug( + `Dynamic client registration is supported at: ${authServerMetadata.registration_endpoint}`, ); } return config; @@ -259,16 +261,15 @@ export class OAuthUtils { } // Fallback: try well-known endpoints at the base URL - console.debug(`Trying OAuth discovery fallback at ${serverUrl}`); + debugLogger.debug(`Trying OAuth discovery fallback at ${serverUrl}`); const authServerMetadata = await this.discoverAuthorizationServerMetadata(serverUrl); if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); if (authServerMetadata.registration_endpoint) { - console.log( - 'Dynamic client registration is supported at:', - authServerMetadata.registration_endpoint, + debugLogger.debug( + `Dynamic client registration is supported at: ${authServerMetadata.registration_endpoint}`, ); } return config; @@ -276,7 +277,7 @@ export class OAuthUtils { return null; } catch (error) { - console.debug( + debugLogger.debug( `Failed to discover OAuth configuration: ${getErrorMessage(error)}`, ); return null; diff --git a/packages/core/src/mcp/sa-impersonation-provider.ts b/packages/core/src/mcp/sa-impersonation-provider.ts index def86591e..a51e32bf0 100644 --- a/packages/core/src/mcp/sa-impersonation-provider.ts +++ b/packages/core/src/mcp/sa-impersonation-provider.ts @@ -14,6 +14,9 @@ import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import { MCP_SA_IMPERSONATION_CLIENT_NAME } from './constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MCP_SA_IMPERSONATION'); const fiveMinBufferMs = 5 * 60 * 1000; @@ -105,11 +108,11 @@ export class ServiceAccountImpersonationProvider idToken = res.data.token; if (!idToken || idToken.length === 0) { - console.error('Failed to get ID token from Google'); + debugLogger.error('Failed to get ID token from Google'); return undefined; } } catch (e) { - console.error('Failed to fetch ID token from Google:', e); + debugLogger.error(`Failed to fetch ID token from Google: ${e}`); return undefined; } @@ -163,7 +166,9 @@ export class ServiceAccountImpersonationProvider return payload.exp * 1000; // Convert seconds to milliseconds } } catch (e) { - console.error('Failed to parse ID token for expiry time with error:', e); + debugLogger.error( + `Failed to parse ID token for expiry time with error: ${e}`, + ); } // Return undefined if try block fails or 'exp' is missing/invalid diff --git a/packages/core/src/mcp/token-storage/keychain-token-storage.ts b/packages/core/src/mcp/token-storage/keychain-token-storage.ts index 4f7397967..e11661d0b 100644 --- a/packages/core/src/mcp/token-storage/keychain-token-storage.ts +++ b/packages/core/src/mcp/token-storage/keychain-token-storage.ts @@ -7,6 +7,9 @@ import * as crypto from 'node:crypto'; import { BaseTokenStorage } from './base-token-storage.js'; import type { OAuthCredentials } from './types.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MCP_KEYCHAIN'); interface Keytar { getPassword(service: string, account: string): Promise; @@ -43,7 +46,7 @@ export class KeychainTokenStorage extends BaseTokenStorage { const module = await import(moduleName); this.keytarModule = module.default || module; } catch (error) { - console.error(error); + debugLogger.error(`Failed to load keytar module: ${error}`); } return this.keytarModule; } @@ -141,7 +144,7 @@ export class KeychainTokenStorage extends BaseTokenStorage { .filter((cred) => !cred.account.startsWith(SECRET_PREFIX)) .map((cred: { account: string }) => cred.account); } catch (error) { - console.error('Failed to list servers from keychain:', error); + debugLogger.error(`Failed to list servers from keychain: ${error}`); return []; } } @@ -169,14 +172,15 @@ export class KeychainTokenStorage extends BaseTokenStorage { result.set(cred.account, data); } } catch (error) { - console.error( - `Failed to parse credentials for ${cred.account}:`, - error, + debugLogger.error( + `Failed to parse credentials for ${cred.account}: ${error}`, ); } } } catch (error) { - console.error('Failed to get all credentials from keychain:', error); + debugLogger.error( + `Failed to get all credentials from keychain: ${error}`, + ); } return result; @@ -304,7 +308,7 @@ export class KeychainTokenStorage extends BaseTokenStorage { .filter((cred) => cred.account.startsWith(SECRET_PREFIX)) .map((cred) => cred.account.substring(SECRET_PREFIX.length)); } catch (error) { - console.error('Failed to list secrets from keychain:', error); + debugLogger.error(`Failed to list secrets from keychain: ${error}`); return []; } } diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 7574ae5d8..3a5993603 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js'; import { AuthType } from '../core/contentGenerator.js'; import type { ModelProvidersConfig } from './types.js'; @@ -270,41 +270,21 @@ describe('ModelRegistry', () => { expect(geminiModels[0].id).toBe('gemini-pro'); }); - it('should skip invalid authType keys with warning', () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - + it('should skip invalid authType keys', () => { const registry = new ModelRegistry({ openai: [{ id: 'gpt-4', name: 'GPT-4' }], 'invalid-key': [{ id: 'some-model', name: 'Some Model' }], } as unknown as ModelProvidersConfig); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[ModelRegistry] Invalid authType key'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('invalid-key'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Expected one of:'), - ); - // Valid key should be registered expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); // Invalid key should be skipped (no crash) const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); expect(openaiModels.length).toBe(1); - - consoleWarnSpy.mockRestore(); }); it('should handle mixed valid and invalid keys', () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - const registry = new ModelRegistry({ openai: [{ id: 'gpt-4', name: 'GPT-4' }], 'bad-key-1': [{ id: 'model-1', name: 'Model 1' }], @@ -312,15 +292,6 @@ describe('ModelRegistry', () => { 'bad-key-2': [{ id: 'model-2', name: 'Model 2' }], } as unknown as ModelProvidersConfig); - // Should warn twice for the two invalid keys - expect(consoleWarnSpy).toHaveBeenCalledTimes(2); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('bad-key-1'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('bad-key-2'), - ); - // Valid keys should be registered expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1); @@ -331,43 +302,9 @@ describe('ModelRegistry', () => { const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI); expect(geminiModels.length).toBe(1); - - consoleWarnSpy.mockRestore(); - }); - - it('should list all valid AuthType values in warning message', () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - - new ModelRegistry({ - 'invalid-auth': [{ id: 'model', name: 'Model' }], - } as unknown as ModelProvidersConfig); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('openai'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('qwen-oauth'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('gemini'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('vertex-ai'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('anthropic'), - ); - - consoleWarnSpy.mockRestore(); }); it('should work correctly with getModelsForAuthType after validation', () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - const registry = new ModelRegistry({ openai: [ { id: 'gpt-4', name: 'GPT-4' }, @@ -381,8 +318,6 @@ describe('ModelRegistry', () => { expect(models.find((m) => m.id === 'gpt-4')).toBeDefined(); expect(models.find((m) => m.id === 'gpt-3.5')).toBeDefined(); expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined(); - - consoleWarnSpy.mockRestore(); }); }); }); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index e288dd772..bb9b5b8b1 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -14,6 +14,9 @@ import { } from './types.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { QWEN_OAUTH_MODELS } from './constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MODEL_REGISTRY'); export { QWEN_OAUTH_MODELS } from './constants.js'; @@ -62,8 +65,8 @@ export class ModelRegistry { const authType = validateAuthTypeKey(rawKey); if (!authType) { - console.warn( - `[ModelRegistry] Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`, + debugLogger.warn( + `Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`, ); continue; } diff --git a/packages/core/src/prompts/prompt-registry.ts b/packages/core/src/prompts/prompt-registry.ts index aac761ab2..8a0367f77 100644 --- a/packages/core/src/prompts/prompt-registry.ts +++ b/packages/core/src/prompts/prompt-registry.ts @@ -5,6 +5,9 @@ */ import type { DiscoveredMCPPrompt } from '../tools/mcp-client.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('PROMPT_REGISTRY'); export class PromptRegistry { private prompts: Map = new Map(); @@ -16,7 +19,7 @@ export class PromptRegistry { registerPrompt(prompt: DiscoveredMCPPrompt): void { if (this.prompts.has(prompt.name)) { const newName = `${prompt.serverName}_${prompt.name}`; - console.warn( + debugLogger.warn( `Prompt with name "${prompt.name}" is already registered. Renaming to "${newName}".`, ); this.prompts.set(newName, { ...prompt, name: newName }); diff --git a/packages/core/src/qwen/qwenContentGenerator.test.ts b/packages/core/src/qwen/qwenContentGenerator.test.ts index 8efdd530b..86c402477 100644 --- a/packages/core/src/qwen/qwenContentGenerator.test.ts +++ b/packages/core/src/qwen/qwenContentGenerator.test.ts @@ -1614,8 +1614,6 @@ describe('QwenContentGenerator', () => { describe('Edge Cases and Error Conditions', () => { it('should handle token retrieval with warning when SharedTokenManager fails', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const mockTokenManager = { getValidCredentials: vi .fn() @@ -1641,13 +1639,6 @@ describe('QwenContentGenerator', () => { await expect( newGenerator.generateContent(request, 'test-prompt-id'), ).rejects.toThrow('Failed to obtain valid Qwen access token'); - - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to get token from shared manager:', - expect.any(Error), - ); - - consoleSpy.mockRestore(); SharedTokenManager.getInstance = originalGetInstance; }); diff --git a/packages/core/src/qwen/qwenContentGenerator.ts b/packages/core/src/qwen/qwenContentGenerator.ts index 0b0e249fa..9cf8aa49b 100644 --- a/packages/core/src/qwen/qwenContentGenerator.ts +++ b/packages/core/src/qwen/qwenContentGenerator.ts @@ -19,11 +19,13 @@ import type { } from '@google/genai'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; /** * Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh */ export class QwenContentGenerator extends OpenAIContentGenerator { + private readonly debugLogger = createDebugLogger('QWEN'); private qwenClient: IQwenOAuth2Client; private sharedManager: SharedTokenManager; private currentToken?: string; @@ -102,7 +104,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator { if (this.isAuthError(error)) { throw error; } - console.warn('Failed to get token from shared manager:', error); + this.debugLogger.warn('Failed to get token from shared manager:', error); throw new Error( 'Failed to obtain valid Qwen access token. Please re-authenticate.', ); diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 0d51f047e..7ff3207d8 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -1682,20 +1682,11 @@ describe('Enhanced Error Handling and Edge Cases', () => { .mockRejectedValue(new Error('Manager failed')), }; - // Mock console.warn to avoid test noise - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = await client.getAccessToken(); // With our race condition fix, we no longer fall back to local credentials // to ensure single source of truth expect(result.token).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to get access token from shared manager:', - expect.any(Error), - ); - - consoleSpy.mockRestore(); }); it('should return undefined when both manager and cache fail', async () => { @@ -1718,13 +1709,9 @@ describe('Enhanced Error Handling and Edge Cases', () => { .mockRejectedValue(new Error('Manager failed')), }; - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = await client.getAccessToken(); expect(result.token).toBeUndefined(); - - consoleSpy.mockRestore(); }); it('should handle missing credentials gracefully', async () => { @@ -1744,13 +1731,9 @@ describe('Enhanced Error Handling and Edge Cases', () => { .mockRejectedValue(new Error('No credentials')), }; - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = await client.getAccessToken(); expect(result.token).toBeUndefined(); - - consoleSpy.mockRestore(); }); }); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 940bdcb18..4a7761300 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -14,12 +14,15 @@ import { EventEmitter } from 'events'; import type { Config } from '../config/config.js'; import { randomUUID } from 'node:crypto'; import { formatFetchErrorForUser } from '../utils/fetch.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { SharedTokenManager, TokenManagerError, TokenError, } from './sharedTokenManager.js'; +const debugLogger = createDebugLogger('QWEN_OAUTH'); + // OAuth Endpoints const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; @@ -273,7 +276,10 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { const credentials = await this.sharedManager.getValidCredentials(this); return { token: credentials.access_token }; } catch (error) { - console.warn('Failed to get access token from shared manager:', error); + debugLogger.warn( + 'Failed to get access token from shared manager:', + error, + ); // Don't use fallback to local credentials to prevent race conditions // All token management should go through SharedTokenManager for consistency @@ -312,7 +318,7 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { } const result = (await response.json()) as DeviceAuthorizationResponse; - console.debug('Device authorization result:', result); + debugLogger.debug('Device authorization result:', result); // Check if the response indicates success if (!isDeviceAuthorizationSuccess(result)) { @@ -498,20 +504,22 @@ export async function getQwenOAuthClient( if (error instanceof TokenManagerError) { switch (error.type) { case TokenError.NO_REFRESH_TOKEN: - console.debug( + debugLogger.debug( 'No refresh token available, proceeding with device flow', ); break; case TokenError.REFRESH_FAILED: - console.debug('Token refresh failed, proceeding with device flow'); + debugLogger.debug( + 'Token refresh failed, proceeding with device flow', + ); break; case TokenError.NETWORK_ERROR: - console.warn( + debugLogger.warn( 'Network error during token refresh, trying device flow', ); break; default: - console.warn('Token manager error:', (error as Error).message); + debugLogger.warn('Token manager error:', (error as Error).message); } } @@ -561,10 +569,10 @@ export async function getQwenOAuthClient( /** * Displays a formatted box with OAuth device authorization URL. - * Uses process.stderr.write() to bypass ConsolePatcher and ensure the auth URL - * is always visible to users, especially in non-interactive mode. - * Using stderr prevents corruption of structured JSON output (which goes to stdout) - * and follows the standard Unix convention of user-facing messages to stderr. + * Uses process.stderr.write() to ensure the auth URL is always visible to users, + * especially in non-interactive mode. Using stderr prevents corruption of + * structured JSON output (which goes to stdout) and follows the standard Unix + * convention of user-facing messages to stderr. */ function showFallbackMessage(verificationUriComplete: string): void { const title = 'Qwen OAuth Device Authorization'; @@ -680,7 +688,7 @@ async function authWithQwenDeviceFlow( return null; } const message = 'Authentication cancelled by user.'; - console.debug('\n' + message); + debugLogger.debug('\n' + message); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); return { success: false, reason: 'cancelled', message }; }; @@ -704,14 +712,11 @@ async function authWithQwenDeviceFlow( // causing the entire Node.js process to crash. if (childProcess) { childProcess.on('error', (err) => { - console.debug( - 'Browser launch failed:', - err.message || 'Unknown error', - ); + debugLogger.debug('Browser launch failed:', err.message || err); }); } } catch (err) { - console.debug( + debugLogger.debug( 'Failed to open browser:', err instanceof Error ? err.message : 'Unknown error', ); @@ -750,7 +755,7 @@ async function authWithQwenDeviceFlow( } emitAuthProgress('polling', 'Waiting for authorization...'); - console.debug('Waiting for authorization...\n'); + debugLogger.debug('Waiting for authorization...\n'); // Poll for the token let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received @@ -766,7 +771,7 @@ async function authWithQwenDeviceFlow( } try { - console.debug('polling for token...'); + debugLogger.debug('polling for token...'); const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier, @@ -810,7 +815,9 @@ async function authWithQwenDeviceFlow( 'Authentication successful! Access token obtained.', ); - console.debug('Authentication successful! Access token obtained.'); + debugLogger.debug( + 'Authentication successful! Access token obtained.', + ); return { success: true }; } @@ -821,7 +828,7 @@ async function authWithQwenDeviceFlow( // Handle slow_down error by increasing poll interval if (pendingData.slowDown) { pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds - console.debug( + debugLogger.debug( `\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`, ); } else { @@ -889,7 +896,6 @@ async function authWithQwenDeviceFlow( eventType: 'error' | 'rate_limit' = 'error', ): AuthResult => { emitAuthProgress(eventType, message); - console.error('\n' + message); return { success: false, reason, message }; }; @@ -930,7 +936,6 @@ async function authWithQwenDeviceFlow( const timeoutMessage = 'Authorization timeout, please restart the process.'; emitAuthProgress('timeout', timeoutMessage); - console.error('\n' + timeoutMessage); return { success: false, reason: 'timeout', message: timeoutMessage }; } catch (error: unknown) { const fullErrorMessage = formatFetchErrorForUser(error, { @@ -939,7 +944,6 @@ async function authWithQwenDeviceFlow( const message = `Device authorization flow failed: ${fullErrorMessage}`; emitAuthProgress('error', message); - console.error(message); return { success: false, reason: 'error', message }; } finally { // Clean up event listener @@ -983,7 +987,7 @@ export async function clearQwenCredentials(): Promise { try { const filePath = getQwenCachedCredentialPath(); await fs.unlink(filePath); - console.debug('Cached Qwen credentials cleared successfully.'); + debugLogger.debug('Cached Qwen credentials cleared successfully.'); } catch (error: unknown) { // If file doesn't exist or can't be deleted, we consider it cleared if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -991,7 +995,10 @@ export async function clearQwenCredentials(): Promise { return; } // Log other errors but don't throw - clearing credentials should be non-critical - console.warn('Warning: Failed to clear cached Qwen credentials:', error); + debugLogger.warn( + 'Warning: Failed to clear cached Qwen credentials:', + error, + ); } finally { // Also clear SharedTokenManager in-memory cache to prevent stale credentials // from being reused within the same process after the file is removed. diff --git a/packages/core/src/qwen/sharedTokenManager.ts b/packages/core/src/qwen/sharedTokenManager.ts index 5b769bbe6..3af295319 100644 --- a/packages/core/src/qwen/sharedTokenManager.ts +++ b/packages/core/src/qwen/sharedTokenManager.ts @@ -17,6 +17,9 @@ import { isErrorResponse, CredentialsClearRequiredError, } from './qwenOAuth2.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('QWEN_OAUTH'); // File System Configuration const QWEN_DIR = '.qwen'; @@ -442,7 +445,9 @@ export class SharedTokenManager { error instanceof Error && error.message.includes('Invalid credentials') ) { - console.warn(`Failed to validate credentials file: ${error.message}`); + debugLogger.warn( + `Failed to validate credentials file: ${error.message}`, + ); } // Clear credentials but preserve other cache state this.memoryCache.credentials = null; @@ -482,7 +487,7 @@ export class SharedTokenManager { const lockAcquisitionTime = Date.now() - startTime; if (lockAcquisitionTime > 5000) { // 5 seconds warning threshold - console.warn( + debugLogger.warn( `Token refresh lock acquisition took ${lockAcquisitionTime}ms`, ); } @@ -508,7 +513,9 @@ export class SharedTokenManager { const totalOperationTime = Date.now() - startTime; if (totalOperationTime > 10000) { // 10 seconds warning threshold - console.warn(`Token refresh operation took ${totalOperationTime}ms`); + debugLogger.warn( + `Token refresh operation took ${totalOperationTime}ms`, + ); } if (!response || isErrorResponse(response)) { @@ -549,7 +556,7 @@ export class SharedTokenManager { } catch (error) { // Handle credentials clear required error (400 status from refresh) if (error instanceof CredentialsClearRequiredError) { - console.debug( + debugLogger.debug( 'SharedTokenManager: Clearing memory cache due to credentials clear requirement', ); // Clear memory cache when credentials need to be cleared @@ -725,13 +732,13 @@ export class SharedTokenManager { await fs.rename(lockPath, tempPath); // Clean up the temporary file await fs.unlink(tempPath); - console.warn( + debugLogger.warn( `Removed stale lock file: ${lockPath} (age: ${lockAge}ms)`, ); continue; // Retry lock acquisition immediately } catch (renameError) { // Lock might have been removed by another process, continue trying - console.warn( + debugLogger.warn( `Failed to remove stale lock file ${lockPath}: ${renameError instanceof Error ? renameError.message : String(renameError)}`, ); // Continue - the lock might have been removed by another process @@ -739,7 +746,7 @@ export class SharedTokenManager { } } catch (statError) { // Can't stat lock file, it might have been removed, continue trying - console.warn( + debugLogger.warn( `Failed to stat lock file ${lockPath}: ${statError instanceof Error ? statError.message : String(statError)}`, ); } @@ -776,7 +783,7 @@ export class SharedTokenManager { // Lock file might already be removed by another process or timeout cleanup // This is not an error condition, but log for debugging if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn( + debugLogger.warn( `Failed to release lock file ${lockPath}: ${error instanceof Error ? error.message : String(error)}`, ); } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 57306dac8..795ac1fe5 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -17,6 +17,9 @@ import { } from '@google/genai'; import * as jsonl from '../utils/jsonl-utils.js'; import { getGitBranch } from '../utils/gitUtils.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('CHAT_RECORDING'); import type { ChatCompressionInfo, ToolCallResponseInfo, @@ -266,7 +269,7 @@ export class ChatRecordingService { jsonl.writeLineSync(conversationFile, record); this.lastRecordUuid = record.uuid; } catch (error) { - console.error('Error appending record:', error); + debugLogger.error('Error appending record:', error); throw error; } } @@ -285,7 +288,7 @@ export class ChatRecordingService { }; this.appendRecord(record); } catch (error) { - console.error('Error saving user message:', error); + debugLogger.error('Error saving user message:', error); } } @@ -319,7 +322,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving assistant turn:', error); + debugLogger.error('Error saving assistant turn:', error); } } @@ -363,7 +366,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving tool result:', error); + debugLogger.error('Error saving tool result:', error); } } @@ -383,7 +386,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving slash command record:', error); + debugLogger.error('Error saving slash command record:', error); } } @@ -403,7 +406,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving chat compression record:', error); + debugLogger.error('Error saving chat compression record:', error); } } @@ -421,7 +424,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving ui telemetry record:', error); + debugLogger.error('Error saving ui telemetry record:', error); } } @@ -439,7 +442,7 @@ export class ChatRecordingService { this.appendRecord(record); } catch (error) { - console.error('Error saving @-command record:', error); + debugLogger.error('Error saving @-command record:', error); } } } diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 7d60ac897..c7629e134 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -644,6 +644,12 @@ describe('LoopDetectionService LLM Checks', () => { getGeminiClient: () => mockGeminiClient, getBaseLlmClient: () => mockBaseLlmClient, getDebugMode: () => false, + getDebugLogger: () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }), getTelemetryEnabled: () => true, getModel: () => 'test-model', } as unknown as Config; diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 095ef0ab4..9117d0120 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -23,6 +23,9 @@ import { isFunctionResponse, } from '../utils/messageInspectors.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LOOP_DETECTION'); const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; @@ -431,14 +434,14 @@ export class LoopDetectionService { }); } catch (e) { // Do nothing, treat it as a non-loop. - this.config.getDebugMode() ? console.error(e) : console.debug(e); + this.config.getDebugLogger().error(e); return false; } if (typeof result['confidence'] === 'number') { if (result['confidence'] > 0.9) { if (typeof result['reasoning'] === 'string' && result['reasoning']) { - console.warn(result['reasoning']); + debugLogger.warn(result['reasoning']); } logLoopDetected( this.config, diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ca70dd565..b3630e6ad 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -17,6 +17,9 @@ import type { UiTelemetryRecordPayload, } from './chatRecordingService.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SESSION'); /** * Session item for list display. @@ -324,7 +327,7 @@ export class SessionService { return await jsonl.read(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Error reading session file:', error); + debugLogger.error('Error reading session file:', error); } return []; } diff --git a/packages/core/src/skills/skill-load.ts b/packages/core/src/skills/skill-load.ts index b2df7733c..dc6f2c616 100644 --- a/packages/core/src/skills/skill-load.ts +++ b/packages/core/src/skills/skill-load.ts @@ -2,18 +2,27 @@ import type { SkillConfig, SkillValidationResult } from './types.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import { parse as parseYaml } from '../utils/yaml-parser.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SKILL_LOAD'); const SKILL_MANIFEST_FILE = 'SKILL.md'; export async function loadSkillsFromDir( baseDir: string, ): Promise { + debugLogger.debug(`Loading skills from directory (skill-load): ${baseDir}`); try { const entries = await fs.readdir(baseDir, { withFileTypes: true }); const skills: SkillConfig[] = []; + debugLogger.debug(`Found ${entries.length} entries in ${baseDir}`); + for (const entry of entries) { // Only process directories (each skill is a directory) - if (!entry.isDirectory()) continue; + if (!entry.isDirectory()) { + debugLogger.warn(`Skipping non-directory entry: ${entry.name}`); + continue; + } const skillDir = path.join(baseDir, entry.name); const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); @@ -26,15 +35,23 @@ export async function loadSkillsFromDir( const config = parseSkillContent(content, skillManifest); skills.push(config); } catch (error) { - console.warn( - `Failed to parse skill at ${skillDir}: ${error instanceof Error ? error.message : 'Unknown error'}`, + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.error( + `Failed to parse skill at ${skillDir}: ${errorMessage}`, ); continue; } } + return skills; - } catch (_error) { + } catch (error) { // Directory doesn't exist or can't be read + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.debug( + `Cannot read skills directory ${baseDir}: ${errorMessage}`, + ); return []; } } @@ -58,6 +75,8 @@ export function parseSkillContent( content: string, filePath: string, ): SkillConfig { + debugLogger.debug(`Parsing skill content from: ${filePath}`); + // Normalize content to handle BOM and CRLF line endings const normalizedContent = normalizeSkillFileContent(content); @@ -118,6 +137,7 @@ export function parseSkillContent( throw new Error(`Validation failed: ${validation.errors.join(', ')}`); } + debugLogger.debug(`Successfully parsed skill: ${name} from ${filePath}`); return config; } diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index bdfe0337d..8ee69e9a0 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -19,6 +19,9 @@ import type { import { SkillError, SkillErrorCode } from './types.js'; import type { Config } from '../config/config.js'; import { validateConfig } from './skill-load.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SKILL_MANAGER'); const QWEN_CONFIG_DIR = '.qwen'; const SKILLS_CONFIG_DIR = 'skills'; @@ -57,7 +60,7 @@ export class SkillManager { try { listener(); } catch (error) { - console.warn('Skill change listener threw an error:', error); + debugLogger.warn('Skill change listener threw an error:', error); } } } @@ -77,6 +80,9 @@ export class SkillManager { * @returns Array of skill configurations */ async listSkills(options: ListSkillsOptions = {}): Promise { + debugLogger.debug( + `Listing skills${options.level ? ` at level: ${options.level}` : ''}${options.force ? ' (forced refresh)' : ''}`, + ); const skills: SkillConfig[] = []; const seenNames = new Set(); @@ -89,16 +95,25 @@ export class SkillManager { // Initialize cache if it doesn't exist or we're forcing a refresh if (!shouldUseCache) { + debugLogger.debug('Cache miss or force refresh, reloading skills'); await this.refreshCache(); + } else { + debugLogger.debug('Using cached skills'); } // Collect skills from each level (project takes precedence over user over extension) for (const level of levelsToCheck) { const levelSkills = this.skillsCache?.get(level) || []; + debugLogger.debug( + `Processing ${levelSkills.length} ${level} level skills`, + ); for (const skill of levelSkills) { // Skip if we've already seen this name (precedence: project > user > extension) if (seenNames.has(skill.name)) { + debugLogger.debug( + `Skipping duplicate skill: ${skill.name} (${level})`, + ); continue; } @@ -110,6 +125,7 @@ export class SkillManager { // Sort by name for consistent ordering skills.sort((a, b) => a.name.localeCompare(b.name)); + debugLogger.info(`Listed ${skills.length} unique skills`); return skills; } @@ -126,24 +142,42 @@ export class SkillManager { name: string, level?: SkillLevel, ): Promise { + debugLogger.debug( + `Loading skill: ${name}${level ? ` at level: ${level}` : ''}`, + ); + if (level) { - return this.findSkillByNameAtLevel(name, level); + const skill = await this.findSkillByNameAtLevel(name, level); + if (skill) { + debugLogger.debug(`Found skill ${name} at ${level} level`); + } else { + debugLogger.debug(`Skill ${name} not found at ${level} level`); + } + return skill; } // Try project level first const projectSkill = await this.findSkillByNameAtLevel(name, 'project'); if (projectSkill) { + debugLogger.debug(`Found skill ${name} at project level`); return projectSkill; } // Try user level first const userSkill = await this.findSkillByNameAtLevel(name, 'user'); if (userSkill) { + debugLogger.debug(`Found skill ${name} at user level`); return userSkill; } // Try extension level - return this.findSkillByNameAtLevel(name, 'extension'); + const extensionSkill = await this.findSkillByNameAtLevel(name, 'extension'); + if (extensionSkill) { + debugLogger.debug(`Found skill ${name} at extension level`); + } else { + debugLogger.debug(`Skill ${name} not found at any level`); + } + return extensionSkill; } /** @@ -158,11 +192,18 @@ export class SkillManager { name: string, level?: SkillLevel, ): Promise { + debugLogger.debug( + `Loading skill for runtime: ${name}${level ? ` at level: ${level}` : ''}`, + ); const skill = await this.loadSkill(name, level); if (!skill) { + debugLogger.debug(`Skill not found for runtime: ${name}`); return null; } + debugLogger.info( + `Skill loaded for runtime: ${name} from ${skill.filePath}`, + ); return skill; } @@ -180,17 +221,24 @@ export class SkillManager { * Refreshes the skills cache by loading all skills from disk. */ async refreshCache(): Promise { + debugLogger.info('Refreshing skills cache...'); const skillsCache = new Map(); this.parseErrors.clear(); const levels: SkillLevel[] = ['project', 'user', 'extension']; + let totalSkills = 0; for (const level of levels) { const levelSkills = await this.listSkillsAtLevel(level); skillsCache.set(level, levelSkills); + totalSkills += levelSkills.length; + debugLogger.debug(`Loaded ${levelSkills.length} ${level} level skills`); } this.skillsCache = skillsCache; + debugLogger.info( + `Skills cache refreshed: ${totalSkills} total skills loaded`, + ); this.notifyChangeListeners(); } @@ -199,22 +247,26 @@ export class SkillManager { */ async startWatching(): Promise { if (this.watchStarted) { + debugLogger.debug('Skill watching already started, skipping'); return; } + debugLogger.info('Starting skill directory watchers...'); this.watchStarted = true; await this.ensureUserSkillsDir(); await this.refreshCache(); this.updateWatchersFromCache(); + debugLogger.info('Skill directory watchers started'); } /** * Stops watching skill directories for changes. */ stopWatching(): void { + debugLogger.info('Stopping skill directory watchers...'); for (const watcher of this.watchers.values()) { void watcher.close().catch((error) => { - console.warn('Failed to close skills watcher:', error); + debugLogger.warn('Failed to close skills watcher:', error); }); } this.watchers.clear(); @@ -223,6 +275,7 @@ export class SkillManager { clearTimeout(this.refreshTimer); this.refreshTimer = null; } + debugLogger.info('Skill directory watchers stopped'); } /** @@ -249,8 +302,13 @@ export class SkillManager { try { content = await fs.readFile(filePath, 'utf8'); } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.error( + `Failed to read skill file ${filePath}: ${errorMessage}`, + ); const skillError = new SkillError( - `Failed to read skill file: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Failed to read skill file: ${errorMessage}`, SkillErrorCode.FILE_ERROR, ); this.parseErrors.set(filePath, skillError); @@ -335,6 +393,9 @@ export class SkillManager { throw new Error(`Validation failed: ${validation.errors.join(', ')}`); } + debugLogger.debug( + `Successfully parsed skill: ${name} (${level}) from ${filePath}`, + ); return config; } catch (error) { const skillError = new SkillError( @@ -379,6 +440,9 @@ export class SkillManager { // If project level is requested but project root is same as home directory, // return empty array to avoid conflicts between project and global skills if (level === 'project' && isHomeDirectory) { + debugLogger.debug( + 'Skipping project-level skills: project root is home directory', + ); return []; } @@ -390,12 +454,16 @@ export class SkillManager { skills.push(skill); }); } - + debugLogger.debug( + `Loaded ${skills.length} extension-level skills from ${extensions.length} extensions`, + ); return skills; } const baseDir = this.getSkillsBaseDir(level); + debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`); const skills = await this.loadSkillsFromDir(baseDir, level); + debugLogger.debug(`Loaded ${skills.length} ${level} level skills`); return skills; } @@ -403,15 +471,21 @@ export class SkillManager { baseDir: string, level: SkillLevel, ): Promise { + debugLogger.debug(`Loading skills from directory: ${baseDir}`); try { const entries = await fs.readdir(baseDir, { withFileTypes: true }); const skills: SkillConfig[] = []; + debugLogger.debug(`Found ${entries.length} entries in ${baseDir}`); + for (const entry of entries) { // Check if it's a directory or a symlink const isDirectory = entry.isDirectory(); const isSymlink = entry.isSymbolicLink(); - if (!isDirectory && !isSymlink) continue; + if (!isDirectory && !isSymlink) { + debugLogger.warn(`Skipping non-directory entry: ${entry.name}`); + continue; + } const skillDir = path.join(baseDir, entry.name); @@ -420,13 +494,13 @@ export class SkillManager { try { const targetStat = await fs.stat(skillDir); if (!targetStat.isDirectory()) { - console.warn( + debugLogger.warn( `Skipping symlink ${entry.name} that does not point to a directory`, ); continue; } } catch (error) { - console.warn( + debugLogger.warn( `Skipping invalid symlink ${entry.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); continue; @@ -448,16 +522,26 @@ export class SkillManager { // Skip directories without valid SKILL.md if (error instanceof SkillError) { // Parse error was already recorded - console.warn( + debugLogger.error( `Failed to parse skill at ${skillDir}: ${error.message}`, ); + } else { + debugLogger.debug( + `No valid SKILL.md found in ${skillDir}, skipping`, + ); } continue; } } + return skills; - } catch (_error) { + } catch (error) { // Directory doesn't exist or can't be read + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.debug( + `Cannot read skills directory ${baseDir}: ${errorMessage}`, + ); return []; } } @@ -508,7 +592,7 @@ export class SkillManager { .get(existingPath) ?.close() .catch((error) => { - console.warn( + debugLogger.warn( `Failed to close skills watcher for ${existingPath}:`, error, ); @@ -530,11 +614,11 @@ export class SkillManager { this.scheduleRefresh(); }) .on('error', (error) => { - console.warn(`Skills watcher error for ${watchPath}:`, error); + debugLogger.warn(`Skills watcher error for ${watchPath}:`, error); }); this.watchers.set(watchPath, watcher); } catch (error) { - console.warn( + debugLogger.warn( `Failed to watch skills directory at ${watchPath}:`, error, ); @@ -558,7 +642,7 @@ export class SkillManager { try { await fs.mkdir(baseDir, { recursive: true }); } catch (error) { - console.warn( + debugLogger.warn( `Failed to create user skills directory at ${baseDir}:`, error, ); diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 2e1aa472f..fea33040c 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -28,6 +28,9 @@ import { SubagentError, SubagentErrorCode } from './types.js'; import { SubagentValidator } from './validation.js'; import { SubAgentScope } from './subagent.js'; import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SUBAGENT_MANAGER'); import { BuiltinAgentRegistry } from './builtin-agents.js'; import { ToolDisplayNamesMigration } from '../tools/tool-names.js'; @@ -59,7 +62,7 @@ export class SubagentManager { try { listener(); } catch (error) { - console.warn('Subagent change listener threw an error:', error); + debugLogger.warn('Subagent change listener threw an error:', error); } } } @@ -699,7 +702,7 @@ export class SubagentManager { // If no match found, preserve the original identifier as-is // This allows for tools that might not be registered yet or custom tools result.push(toolIdentifier); - console.warn( + debugLogger.warn( `Tool "${toolIdentifier}" not found in tool registry, preserving as-is`, ); } diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 11b60ad4b..4f550a36b 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -6,6 +6,9 @@ import { reportError } from '../utils/errorReporting.js'; import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SUBAGENT'); import { type ToolCallRequestInfo } from '../core/turn.js'; import { CoreToolScheduler, @@ -517,7 +520,7 @@ export class SubAgentScope { } as SubAgentRoundEvent); } } catch (error) { - console.error('Error during subagent execution:', error); + debugLogger.error('Error during subagent execution:', error); this.terminateMode = SubagentTerminateMode.ERROR; this.eventEmitter?.emit(SubAgentEventType.ERROR, { subagentId: this.subagentId, diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 41871c36e..749d6eede 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -26,6 +26,13 @@ import { } from '../types.js'; import type { RumEvent, RumPayload } from './event-types.js'; +const debugLoggerSpy = vi.hoisted(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + // Mock dependencies vi.mock('../../utils/user_id.js', () => ({ getInstallationId: vi.fn(() => 'test-installation-id'), @@ -35,6 +42,20 @@ vi.mock('../../utils/safeJsonStringify.js', () => ({ safeJsonStringify: vi.fn((obj) => JSON.stringify(obj)), })); +vi.mock('../../utils/debugLogger.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + createDebugLogger: () => ({ + debug: debugLoggerSpy.debug, + info: debugLoggerSpy.info, + warn: debugLoggerSpy.warn, + error: debugLoggerSpy.error, + }), + }; +}); + // Mock https module vi.mock('https', () => ({ request: vi.fn(), @@ -72,6 +93,10 @@ describe('QwenLogger', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); mockConfig = makeFakeConfig(); + debugLoggerSpy.debug.mockClear(); + debugLoggerSpy.info.mockClear(); + debugLoggerSpy.warn.mockClear(); + debugLoggerSpy.error.mockClear(); // Clear singleton instance // eslint-disable-next-line @typescript-eslint/no-explicit-any (QwenLogger as any).instance = undefined; @@ -126,11 +151,7 @@ describe('QwenLogger', () => { describe('event queue management', () => { it('should handle event overflow gracefully', () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; // Fill the queue beyond capacity for (let i = 0; i < TEST_ONLY.MAX_EVENTS + 10; i++) { @@ -142,20 +163,16 @@ describe('QwenLogger', () => { }); } - // Should have logged debug messages about dropping events - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'QwenLogger: Dropped old event to prevent memory leak', - ), + const events = logger['events'].toArray() as RumEvent[]; + expect(logger['events'].size).toBe(TEST_ONLY.MAX_EVENTS); + expect(events[0]?.name).toBe('test-event-10'); + expect(events[events.length - 1]?.name).toBe( + `test-event-${TEST_ONLY.MAX_EVENTS + 9}`, ); }); it('should handle enqueue errors gracefully', () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; // Mock the events deque to throw an error const originalPush = logger['events'].push; @@ -170,10 +187,7 @@ describe('QwenLogger', () => { name: 'test-event', }); - expect(consoleSpy).toHaveBeenCalledWith( - 'QwenLogger: Failed to enqueue log event.', - expect.any(Error), - ); + expect(logger['events'].size).toBe(0); // Restore original method logger['events'].push = originalPush; @@ -182,11 +196,7 @@ describe('QwenLogger', () => { describe('concurrent flush protection', () => { it('should handle concurrent flush requests', () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; // Manually set the flush in progress flag to simulate concurrent access logger['isFlushInProgress'] = true; @@ -194,12 +204,7 @@ describe('QwenLogger', () => { // Try to flush while another flush is in progress const result = logger.flushToRum(); - // Should have logged about pending flush - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'QwenLogger: Flush already in progress, marking pending flush', - ), - ); + expect(logger['pendingFlush']).toBe(true); // Should return a resolved promise expect(result).toBeInstanceOf(Promise); @@ -211,11 +216,7 @@ describe('QwenLogger', () => { describe('failed event retry mechanism', () => { it('should requeue failed events with size limits', () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; const failedEvents: RumEvent[] = []; for (let i = 0; i < TEST_ONLY.MAX_RETRY_EVENTS + 50; i++) { @@ -231,18 +232,11 @@ describe('QwenLogger', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (logger as any).requeueFailedEvents(failedEvents); - // Should have logged about dropping events due to retry limit - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('QwenLogger: Re-queued'), - ); + expect(logger['events'].size).toBe(TEST_ONLY.MAX_RETRY_EVENTS); }); it('should handle empty retry queue gracefully', () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; // Fill the queue to capacity first for (let i = 0; i < TEST_ONLY.MAX_EVENTS; i++) { @@ -267,9 +261,7 @@ describe('QwenLogger', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (logger as any).requeueFailedEvents(failedEvents); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('QwenLogger: No events re-queued'), - ); + expect(logger['events'].size).toBe(TEST_ONLY.MAX_EVENTS); }); }); @@ -386,11 +378,7 @@ describe('QwenLogger', () => { describe('error handling', () => { it('should handle flush errors gracefully with debug mode', async () => { - const debugConfig = makeFakeConfig({ getDebugMode: () => true }); - const logger = QwenLogger.getInstance(debugConfig)!; - const consoleSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + const logger = QwenLogger.getInstance(mockConfig)!; // Add an event first logger.enqueueLogEvent({ @@ -412,10 +400,8 @@ describe('QwenLogger', () => { // Wait for async operations await vi.runAllTimersAsync(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Error flushing to RUM:', - expect.any(Error), - ); + // Errors are now silently ignored to reduce log spam + // Only rate-limited error logs are emitted inside flushToRum itself // Restore original method logger.flushToRum = originalFlush; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index e5e2d2106..21c09cc37 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -54,6 +54,10 @@ import type { RumOS, } from './event-types.js'; import type { Config } from '../../config/config.js'; +import { + createDebugLogger, + type DebugLogger, +} from '../../utils/debugLogger.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { InstallationManager } from '../../utils/installationManager.js'; import { FixedDeque } from 'mnemonist'; @@ -70,6 +74,11 @@ const RUN_APP_ID = 'gb4w8c3ygj@851d5d500f08f92'; */ const FLUSH_INTERVAL_MS = 1000 * 60; +/** + * Minimum interval between logging network errors to avoid log spam. + */ +const ERROR_LOG_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + /** * Maximum amount of events to keep in memory. Events added after this amount * are dropped until the next flush to RUM, which happens periodically as @@ -91,6 +100,7 @@ export interface LogResponse { export class QwenLogger { private static instance: QwenLogger; private config?: Config; + private debugLogger: DebugLogger; private readonly installationManager: InstallationManager; /** @@ -119,8 +129,14 @@ export class QwenLogger { */ private pendingFlush: boolean = false; + /** + * Timestamp of the last network error log to prevent log spam. + */ + private lastErrorLogTime: number = 0; + private constructor(config: Config) { this.config = config; + this.debugLogger = createDebugLogger('QWEN_LOGGER'); this.events = new FixedDeque(Array, MAX_EVENTS); this.installationManager = new InstallationManager(); this.userId = this.generateUserId(); @@ -154,15 +170,13 @@ export class QwenLogger { this.events.push(event); - if (wasAtCapacity && this.config?.getDebugMode()) { - console.debug( + if (wasAtCapacity) { + this.debugLogger.debug( `QwenLogger: Dropped old event to prevent memory leak (queue size: ${this.events.size})`, ); } } catch (error) { - if (this.config?.getDebugMode()) { - console.error('QwenLogger: Failed to enqueue log event.', error); - } + this.debugLogger.error('QwenLogger: Failed to enqueue log event.', error); } } @@ -265,28 +279,19 @@ export class QwenLogger { return; } - this.flushToRum().catch((error) => { - if (this.config?.getDebugMode()) { - console.debug('Error flushing to RUM:', error); - } - }); + void this.flushToRum(); } async flushToRum(): Promise { if (this.isFlushInProgress) { - if (this.config?.getDebugMode()) { - console.debug( - 'QwenLogger: Flush already in progress, marking pending flush.', - ); - } + this.debugLogger.debug( + 'QwenLogger: Flush already in progress, marking pending flush.', + ); this.pendingFlush = true; return Promise.resolve({}); } this.isFlushInProgress = true; - if (this.config?.getDebugMode()) { - console.log('Flushing log events to RUM.'); - } if (this.events.size === 0) { this.isFlushInProgress = false; return {}; @@ -338,8 +343,11 @@ export class QwenLogger { this.lastFlushTime = Date.now(); return {}; } catch (error) { - if (this.config?.getDebugMode()) { - console.error('RUM flush failed.', error); + // Only log network errors if sufficient time has passed to avoid spam + const now = Date.now(); + if (now - this.lastErrorLogTime > ERROR_LOG_INTERVAL_MS) { + this.debugLogger.error('RUM flush failed.', error); + this.lastErrorLogTime = now; } // Re-queue failed events for retry @@ -352,11 +360,7 @@ export class QwenLogger { if (this.pendingFlush) { this.pendingFlush = false; // Fire and forget the pending flush - this.flushToRum().catch((error) => { - if (this.config?.getDebugMode()) { - console.debug('Error in pending flush to RUM:', error); - } - }); + void this.flushToRum(); } } } @@ -365,14 +369,7 @@ export class QwenLogger { async logStartSessionEvent(event: StartSessionEvent): Promise { // Flush all pending events with the old session ID first. // If flush fails, discard the pending events to avoid mixing sessions. - await this.flushToRum().catch((error: unknown) => { - if (this.config?.getDebugMode()) { - console.debug( - 'Error flushing pending events before session start:', - error, - ); - } - }); + await this.flushToRum(); // Clear any remaining events (discard if flush failed) this.events.clear(); @@ -401,11 +398,7 @@ export class QwenLogger { // Flush start event immediately this.enqueueLogEvent(applicationEvent); - this.flushToRum().catch((error: unknown) => { - if (this.config?.getDebugMode()) { - console.debug('Error flushing to RUM:', error); - } - }); + void this.flushToRum(); } logEndSessionEvent(_event: EndSessionEvent): void { @@ -413,11 +406,7 @@ export class QwenLogger { // Flush immediately on session end. this.enqueueLogEvent(applicationEvent); - this.flushToRum().catch((error: unknown) => { - if (this.config?.getDebugMode()) { - console.debug('Error flushing to RUM:', error); - } - }); + void this.flushToRum(); } logConversationFinishedEvent(event: ConversationFinishedEvent): void { @@ -917,8 +906,8 @@ export class QwenLogger { const eventsToRetry = eventsToSend.slice(-MAX_RETRY_EVENTS); // Keep only the most recent events // Log a warning if we're dropping events - if (eventsToSend.length > MAX_RETRY_EVENTS && this.config?.getDebugMode()) { - console.warn( + if (eventsToSend.length > MAX_RETRY_EVENTS) { + this.debugLogger.warn( `QwenLogger: Dropping ${ eventsToSend.length - MAX_RETRY_EVENTS } events due to retry queue limit. Total events: ${ @@ -932,11 +921,6 @@ export class QwenLogger { const numEventsToRequeue = Math.min(eventsToRetry.length, availableSpace); if (numEventsToRequeue === 0) { - if (this.config?.getDebugMode()) { - console.debug( - `QwenLogger: No events re-queued (queue size: ${this.events.size})`, - ); - } return; } @@ -955,11 +939,9 @@ export class QwenLogger { this.events.pop(); } - if (this.config?.getDebugMode()) { - console.debug( - `QwenLogger: Re-queued ${numEventsToRequeue} events for retry (queue size: ${this.events.size})`, - ); - } + this.debugLogger.debug( + `QwenLogger: Re-queued ${numEventsToRequeue} events for retry (queue size: ${this.events.size})`, + ); } } diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index ee6bc05a7..9274631db 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -46,7 +46,7 @@ describe('Telemetry SDK', () => { }); afterEach(async () => { - await shutdownTelemetry(mockConfig); + await shutdownTelemetry(); }); it('should use gRPC exporters when protocol is grpc', () => { diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index cfaa878ce..3dba2acc4 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -36,6 +36,7 @@ import { FileMetricExporter, FileSpanExporter, } from './file-exporters.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; // For troubleshooting, set the log level to DiagLogLevel.DEBUG diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); @@ -77,6 +78,7 @@ export function initializeTelemetry(config: Config): void { return; } + const debugLogger = createDebugLogger('OTEL'); const resource = resourceFromAttributes({ [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, [SemanticResourceAttributes.SERVICE_VERSION]: process.version, @@ -159,37 +161,34 @@ export function initializeTelemetry(config: Config): void { try { sdk.start(); - if (config.getDebugMode()) { - console.log('OpenTelemetry SDK started successfully.'); - } + debugLogger.debug('OpenTelemetry SDK started successfully.'); telemetryInitialized = true; initializeMetrics(config); } catch (error) { - console.error('Error starting OpenTelemetry SDK:', error); + debugLogger.error('Error starting OpenTelemetry SDK:', error); } process.on('SIGTERM', () => { - shutdownTelemetry(config); + shutdownTelemetry(); }); process.on('SIGINT', () => { - shutdownTelemetry(config); + shutdownTelemetry(); }); process.on('exit', () => { - shutdownTelemetry(config); + shutdownTelemetry(); }); } -export async function shutdownTelemetry(config: Config): Promise { +export async function shutdownTelemetry(): Promise { if (!telemetryInitialized || !sdk) { return; } + const debugLogger = createDebugLogger('OTEL'); try { await sdk.shutdown(); - if (config.getDebugMode()) { - console.log('OpenTelemetry SDK shut down successfully.'); - } + debugLogger.debug('OpenTelemetry SDK shut down successfully.'); } catch (error) { - console.error('Error shutting down SDK:', error); + debugLogger.error('Error shutting down SDK:', error); } finally { telemetryInitialized = false; } diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 6051d226a..ee8003b58 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -44,7 +44,7 @@ describe('telemetry', () => { afterEach(async () => { // Ensure we shut down telemetry even if a test fails. if (isTelemetrySdkInitialized()) { - await shutdownTelemetry(mockConfig); + await shutdownTelemetry(); } }); @@ -56,7 +56,7 @@ describe('telemetry', () => { it('should shutdown the telemetry service', async () => { initializeTelemetry(mockConfig); - await shutdownTelemetry(mockConfig); + await shutdownTelemetry(); expect(mockNodeSdk.shutdown).toHaveBeenCalled(); }); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index e7d8aea7f..016eb2854 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -35,6 +35,7 @@ import type { } from './modifiable-tool.js'; import { IdeClient } from '../ide/ide-client.js'; import { safeLiteralReplace } from '../utils/textUtils.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { countOccurrences, extractEditSnippet, @@ -42,6 +43,8 @@ import { normalizeEditStrings, } from '../utils/editHelper.js'; +const debugLogger = createDebugLogger('EDIT'); + export function applyReplacement( currentContent: string | null, oldString: string, @@ -257,12 +260,12 @@ class EditToolInvocation implements ToolInvocation { throw error; } const errorMsg = error instanceof Error ? error.message : String(error); - console.log(`Error preparing edit: ${errorMsg}`); + debugLogger.warn(`Error preparing edit: ${errorMsg}`); return false; } if (editData.error) { - console.log(`Error: ${editData.error.display}`); + debugLogger.warn(`Error: ${editData.error.display}`); return false; } diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index e3c92f924..d8b3df86f 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -15,6 +15,9 @@ import type { FunctionDeclaration } from '@google/genai'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('EXIT_PLAN_MODE'); export interface ExitPlanModeParams { plan: string; @@ -102,7 +105,7 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error( + debugLogger.error( `[ExitPlanModeTool] Failed to set approval mode to "${mode}": ${errorMessage}`, ); } @@ -135,7 +138,7 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error( + debugLogger.error( `[ExitPlanModeTool] Error executing exit_plan_mode: ${errorMessage}`, ); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index a3b4a5d5a..74af58081 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -19,6 +19,9 @@ import { import { ToolErrorType } from './tool-error.js'; import { getErrorMessage } from '../utils/errors.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('GLOB'); const MAX_FILE_COUNT = 100; @@ -203,7 +206,7 @@ class GlobToolInvocation extends BaseToolInvocation< } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`GlobLogic execute Error: ${errorMessage}`, error); + debugLogger.error(`GlobLogic execute Error: ${errorMessage}`, error); const rawError = `Error during glob search operation: ${errorMessage}`; return { llmContent: rawError, diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 934ab57b2..b8ce6d54f 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -12,6 +12,9 @@ import { globStream } from 'glob'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('GREP'); import { resolveAndValidatePath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { isGitRepository } from '../utils/gitUtils.js'; @@ -183,7 +186,7 @@ class GrepToolInvocation extends BaseToolInvocation< returnDisplay: displayMessage, }; } catch (error) { - console.error(`Error during GrepLogic execution: ${error}`); + debugLogger.error(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); return { llmContent: `Error during grep search operation: ${errorMessage}`, @@ -319,7 +322,7 @@ class GrepToolInvocation extends BaseToolInvocation< }); return this.parseGrepOutput(output, absolutePath); } catch (gitError: unknown) { - console.debug( + debugLogger.debug( `GrepLogic: git grep failed: ${getErrorMessage( gitError, )}. Falling back...`, @@ -421,7 +424,7 @@ class GrepToolInvocation extends BaseToolInvocation< }); return this.parseGrepOutput(output, absolutePath); } catch (grepError: unknown) { - console.debug( + debugLogger.debug( `GrepLogic: System grep failed: ${getErrorMessage( grepError, )}. Falling back...`, @@ -430,7 +433,7 @@ class GrepToolInvocation extends BaseToolInvocation< } // --- Strategy 3: Pure JavaScript Fallback --- - console.debug( + debugLogger.debug( 'GrepLogic: Falling back to JavaScript grep implementation.', ); strategyUsed = 'javascript fallback'; @@ -468,7 +471,7 @@ class GrepToolInvocation extends BaseToolInvocation< } catch (readError: unknown) { // Ignore errors like permission denied or file gone during read if (!isNodeError(readError) || readError.code !== 'ENOENT') { - console.debug( + debugLogger.debug( `GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage( readError, )}`, @@ -479,7 +482,7 @@ class GrepToolInvocation extends BaseToolInvocation< return allMatches; } catch (error: unknown) { - console.error( + debugLogger.error( `GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage( error, )}`, diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 3a3f3a0d9..39a6b7b31 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -253,11 +253,6 @@ describe('LSTool', () => { return originalStat(p); }); - // Spy on console.error to verify it's called - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const invocation = lsTool.build({ path: tempRootDir }); const result = await invocation.execute(abortSignal); @@ -266,13 +261,7 @@ describe('LSTool', () => { expect(result.llmContent).not.toContain('problematic.txt'); expect(result.returnDisplay).toBe('Listed 1 item(s).'); - // Verify error was logged - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringMatching(/Error accessing.*problematic\.txt/s), - ); - statSpy.mockRestore(); - consoleErrorSpy.mockRestore(); }); }); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 6310a6827..b8edbe163 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -14,6 +14,9 @@ import type { Config } from '../config/config.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('LS'); /** * Parameters for the LS tool @@ -202,7 +205,7 @@ class LSToolInvocation extends BaseToolInvocation { }); } catch (error) { // Log error internally but don't fail the whole listing - console.error(`Error accessing ${fullPath}: ${error}`); + debugLogger.warn(`Error accessing ${fullPath}: ${error}`); } } diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 123088699..050875a88 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -15,9 +15,12 @@ import { } from './mcp-client.js'; import type { SendSdkMcpMessage } from './mcp-client.js'; import { getErrorMessage } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { EventEmitter } from 'node:events'; import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +const debugLogger = createDebugLogger('MCP'); + /** * Manages the lifecycle of multiple MCP clients, including local child processes. * This class is responsible for starting, stopping, and discovering tools from @@ -89,7 +92,7 @@ export class McpClientManager { } catch (error) { this.eventEmitter?.emit('mcp-client-update', this.clients); // Log the error but don't let a single failed server stop the others - console.error( + debugLogger.error( `Error during discovery for server '${name}': ${getErrorMessage( error, )}`, @@ -127,7 +130,7 @@ export class McpClientManager { try { await existingClient.disconnect(); } catch (error) { - console.error( + debugLogger.error( `Error stopping client '${serverName}': ${getErrorMessage(error)}`, ); } finally { @@ -159,7 +162,7 @@ export class McpClientManager { await client.discover(cliConfig); } catch (error) { // Log the error but don't throw: callers expect best-effort discovery. - console.error( + debugLogger.error( `Error during discovery for server '${serverName}': ${getErrorMessage( error, )}`, @@ -179,7 +182,7 @@ export class McpClientManager { try { await client.disconnect(); } catch (error) { - console.error( + debugLogger.error( `Error stopping client '${name}': ${getErrorMessage(error)}`, ); } diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index fd9f9d503..37dc94b1f 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -78,9 +78,6 @@ describe('mcp-client', () => { }); it('should not skip tools even if a parameter is missing a type', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); const mockedClient = { connect: vi.fn(), discover: vi.fn(), @@ -137,14 +134,9 @@ describe('mcp-client', () => { await client.connect(); await client.discover({} as Config); expect(mockedToolRegistry.registerTool).toHaveBeenCalledTimes(2); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - consoleWarnSpy.mockRestore(); }); it('should handle errors when discovering prompts', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const mockedClient = { connect: vi.fn(), discover: vi.fn(), @@ -178,10 +170,6 @@ describe('mcp-client', () => { await expect(client.discover({} as Config)).rejects.toThrow( 'No prompts or tools found on the server.', ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Error discovering prompts from test-server: Test error`, - ); - consoleErrorSpy.mockRestore(); }); }); describe('appendMcpServerCommand', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index cfae34506..e61318edc 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -40,6 +40,7 @@ import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; import { OAuthUtils } from '../mcp/oauth-utils.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import { getErrorMessage } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { Unsubscribe, WorkspaceContext, @@ -56,6 +57,8 @@ export type SendSdkMcpMessage = ( export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes +const debugLogger = createDebugLogger('MCP'); + export type DiscoveredMCPPrompt = Prompt & { serverName: string; invoke: (params: Record) => Promise; @@ -125,7 +128,7 @@ export class McpClient { if (this.isDisconnecting) { return; } - console.error(`MCP ERROR (${this.serverName}):`, error.toString()); + debugLogger.error(`MCP ERROR (${this.serverName}):`, error.toString()); this.updateStatus(MCPServerStatus.DISCONNECTED); }; @@ -364,7 +367,7 @@ async function handleAutomaticOAuth( wwwAuthenticate: string, ): Promise { try { - console.log(`🔐 '${mcpServerName}' requires OAuth authentication`); + debugLogger.info(`'${mcpServerName}' requires OAuth authentication`); // Always try to parse the resource metadata URI from the www-authenticate header let oauthConfig; @@ -382,8 +385,8 @@ async function handleAutomaticOAuth( } if (!oauthConfig) { - console.error( - `❌ Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + debugLogger.error( + `Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, ); return false; } @@ -401,18 +404,18 @@ async function handleAutomaticOAuth( // Perform OAuth authentication // Pass the server URL for proper discovery const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; - console.log( + debugLogger.info( `Starting OAuth authentication for server '${mcpServerName}'...`, ); const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); await authProvider.authenticate(mcpServerName, oauthAuthConfig, serverUrl); - console.log( + debugLogger.info( `OAuth authentication successful for server '${mcpServerName}'`, ); return true; } catch (error) { - console.error( + debugLogger.error( `Failed to handle automatic OAuth for server '${mcpServerName}': ${getErrorMessage(error)}`, ); return false; @@ -462,7 +465,7 @@ async function createTransportWithOAuth( return null; } catch (error) { - console.error( + debugLogger.error( `Failed to create OAuth transport for server '${mcpServerName}': ${getErrorMessage(error)}`, ); return null; @@ -565,7 +568,7 @@ export async function connectAndDiscover( ); mcpClient.onerror = (error) => { - console.error(`MCP ERROR (${mcpServerName}):`, error.toString()); + debugLogger.error(`MCP ERROR (${mcpServerName}):`, error.toString()); updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); }; @@ -598,7 +601,7 @@ export async function connectAndDiscover( if (mcpClient) { mcpClient.close(); } - console.error( + debugLogger.error( `Error connecting to MCP server '${mcpServerName}': ${getErrorMessage( error, )}`, @@ -655,7 +658,7 @@ export async function discoverTools( ), ); } catch (error) { - console.error( + debugLogger.error( `Error discovering tool: '${ funcDecl.name }' from MCP server '${mcpServerName}': ${(error as Error).message}`, @@ -668,7 +671,7 @@ export async function discoverTools( error instanceof Error && !error.message?.includes('Method not found') ) { - console.error( + debugLogger.error( `Error discovering tools from ${mcpServerName}: ${getErrorMessage( error, )}`, @@ -715,7 +718,7 @@ export async function discoverPrompts( error instanceof Error && !error.message?.includes('Method not found') ) { - console.error( + debugLogger.error( `Error discovering prompts from ${mcpServerName}: ${getErrorMessage( error, )}`, @@ -758,7 +761,7 @@ export async function invokeMcpPrompt( error instanceof Error && !error.message?.includes('Method not found') ) { - console.error( + debugLogger.error( `Error invoking prompt '${promptName}' from ${mcpServerName} ${promptParams}: ${getErrorMessage( error, )}`, @@ -886,12 +889,12 @@ export async function connectToMcpServer( }, ); if (hasStoredTokens) { - console.log( + debugLogger.warn( `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + `Please re-authenticate using: /mcp auth ${mcpServerName}`, ); } else { - console.log( + debugLogger.warn( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + `Please authenticate using: /mcp auth ${mcpServerName}`, ); @@ -908,7 +911,7 @@ export async function connectToMcpServer( // If we didn't get the header from the error string, try to get it from the server if (!wwwAuthenticate && hasNetworkTransport(mcpServerConfig)) { - console.log( + debugLogger.debug( `No www-authenticate header in error, trying to fetch it from server...`, ); try { @@ -926,13 +929,13 @@ export async function connectToMcpServer( if (response.status === 401) { wwwAuthenticate = response.headers.get('www-authenticate'); if (wwwAuthenticate) { - console.log( + debugLogger.debug( `Found www-authenticate header from server: ${wwwAuthenticate}`, ); } } } catch (fetchError) { - console.debug( + debugLogger.debug( `Failed to fetch www-authenticate header: ${getErrorMessage( fetchError, )}`, @@ -941,7 +944,7 @@ export async function connectToMcpServer( } if (wwwAuthenticate) { - console.log( + debugLogger.debug( `Received 401 with www-authenticate header: ${wwwAuthenticate}`, ); @@ -953,7 +956,7 @@ export async function connectToMcpServer( ); if (oauthSuccess) { // Retry connection with OAuth token - console.log( + debugLogger.info( `Retrying connection to '${mcpServerName}' with OAuth token...`, ); @@ -987,7 +990,7 @@ export async function connectToMcpServer( // Connection successful with OAuth return mcpClient; } catch (retryError) { - console.error( + debugLogger.error( `Failed to connect with OAuth token: ${getErrorMessage( retryError, )}`, @@ -995,7 +998,7 @@ export async function connectToMcpServer( throw retryError; } } else { - console.error( + debugLogger.error( `Failed to create OAuth transport for server '${mcpServerName}'`, ); throw new Error( @@ -1003,7 +1006,7 @@ export async function connectToMcpServer( ); } } else { - console.error( + debugLogger.error( `Failed to get OAuth token for server '${mcpServerName}'`, ); throw new Error( @@ -1011,7 +1014,7 @@ export async function connectToMcpServer( ); } } else { - console.error( + debugLogger.error( `Failed to get credentials for server '${mcpServerName}' after successful OAuth authentication`, ); throw new Error( @@ -1019,7 +1022,7 @@ export async function connectToMcpServer( ); } } else { - console.error( + debugLogger.error( `Failed to handle automatic OAuth for server '${mcpServerName}'`, ); throw new Error( @@ -1046,12 +1049,12 @@ export async function connectToMcpServer( }, ); if (hasStoredTokens) { - console.log( + debugLogger.warn( `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + `Please re-authenticate using: /mcp auth ${mcpServerName}`, ); } else { - console.log( + debugLogger.warn( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + `Please authenticate using: /mcp auth ${mcpServerName}`, ); @@ -1064,7 +1067,9 @@ export async function connectToMcpServer( } // For SSE/HTTP servers, try to discover OAuth configuration from the base URL - console.log(`🔍 Attempting OAuth discovery for '${mcpServerName}'...`); + debugLogger.info( + `Attempting OAuth discovery for '${mcpServerName}'...`, + ); if (hasNetworkTransport(mcpServerConfig)) { const serverUrl = new URL( @@ -1076,7 +1081,7 @@ export async function connectToMcpServer( // Try to discover OAuth configuration from the base URL const oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); if (oauthConfig) { - console.log( + debugLogger.info( `Discovered OAuth configuration from base URL for server '${mcpServerName}'`, ); @@ -1092,7 +1097,7 @@ export async function connectToMcpServer( // Pass the server URL for proper discovery const authServerUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; - console.log( + debugLogger.info( `Starting OAuth authentication for server '${mcpServerName}'...`, ); const authProvider = new MCPOAuthProvider( @@ -1133,7 +1138,7 @@ export async function connectToMcpServer( // Connection successful with OAuth return mcpClient; } catch (retryError) { - console.error( + debugLogger.error( `Failed to connect with OAuth token: ${getErrorMessage( retryError, )}`, @@ -1141,7 +1146,7 @@ export async function connectToMcpServer( throw retryError; } } else { - console.error( + debugLogger.error( `Failed to create OAuth transport for server '${mcpServerName}'`, ); throw new Error( @@ -1149,7 +1154,7 @@ export async function connectToMcpServer( ); } } else { - console.error( + debugLogger.error( `Failed to get OAuth token for server '${mcpServerName}'`, ); throw new Error( @@ -1157,7 +1162,7 @@ export async function connectToMcpServer( ); } } else { - console.error( + debugLogger.error( `Failed to get stored credentials for server '${mcpServerName}'`, ); throw new Error( @@ -1165,22 +1170,22 @@ export async function connectToMcpServer( ); } } else { - console.error( - `❌ Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + debugLogger.error( + `Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, ); throw new Error( `OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`, ); } } catch (discoveryError) { - console.error( - `❌ OAuth discovery failed for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + debugLogger.error( + `OAuth discovery failed for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, ); throw discoveryError; } } else { - console.error( - `❌ '${mcpServerName}' requires authentication but no OAuth configuration found`, + debugLogger.error( + `'${mcpServerName}' requires authentication but no OAuth configuration found`, ); throw new Error( `MCP server '${mcpServerName}' requires authentication. Please configure OAuth or check server settings.`, @@ -1295,7 +1300,7 @@ export async function createTransport( ); if (!accessToken) { - console.error( + debugLogger.error( `MCP server '${mcpServerName}' requires OAuth authentication. ` + `Please authenticate using the /mcp auth command.`, ); @@ -1317,7 +1322,9 @@ export async function createTransport( if (accessToken) { hasOAuthConfig = true; - console.log(`Found stored OAuth token for server '${mcpServerName}'`); + debugLogger.debug( + `Found stored OAuth token for server '${mcpServerName}'`, + ); } } } @@ -1382,7 +1389,7 @@ export async function createTransport( if (debugMode) { transport.stderr!.on('data', (data) => { const stderrStr = data.toString().trim(); - console.debug(`[DEBUG] [MCP STDERR (${mcpServerName})]: `, stderrStr); + debugLogger.debug(`MCP STDERR (${mcpServerName}):`, stderrStr); }); } return transport; @@ -1400,7 +1407,7 @@ export function isEnabled( mcpServerConfig: MCPServerConfig, ): boolean { if (!funcDecl.name) { - console.warn( + debugLogger.warn( `Discovered a function declaration without a name from MCP server '${mcpServerName}'. Skipping.`, ); return false; diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index dc6bebef6..fff2d2be1 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -24,6 +24,9 @@ import type { ModifyContext, } from './modifiable-tool.js'; import { ToolErrorType } from './tool-error.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MEMORY_TOOL'); const memoryToolSchemaData: FunctionDeclaration = { name: 'save_memory', @@ -361,7 +364,7 @@ Project: ${projectPath} (current project only)`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error( + debugLogger.error( `[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`, ); @@ -435,7 +438,7 @@ export class MemoryTool await fsAdapter.writeFile(memoryFilePath, newContent, 'utf-8'); } catch (error) { - console.error( + debugLogger.error( `[MemoryTool] Error adding memory entry to ${memoryFilePath}:`, error, ); diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts index 165ecb128..3fa989c80 100644 --- a/packages/core/src/tools/modifiable-tool.test.ts +++ b/packages/core/src/tools/modifiable-tool.test.ts @@ -263,10 +263,7 @@ describe('modifyWithEditor', () => { }); it('should handle temp file cleanup errors gracefully', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.spyOn(fs, 'unlinkSync').mockImplementation(() => { + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => { throw new Error('Failed to delete file'); }); @@ -278,12 +275,7 @@ describe('modifyWithEditor', () => { vi.fn(), ); - expect(consoleErrorSpy).toHaveBeenCalledTimes(2); - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Error deleting temp diff file:'), - ); - - consoleErrorSpy.mockRestore(); + expect(unlinkSpy).toHaveBeenCalledTimes(2); }); it('should create temp files with correct naming with extension', async () => { diff --git a/packages/core/src/tools/modifiable-tool.ts b/packages/core/src/tools/modifiable-tool.ts index 12fa9a1f9..560e9a847 100644 --- a/packages/core/src/tools/modifiable-tool.ts +++ b/packages/core/src/tools/modifiable-tool.ts @@ -12,12 +12,15 @@ import fs from 'node:fs'; import * as Diff from 'diff'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { isNodeError } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { AnyDeclarativeTool, DeclarativeTool, ToolResult, } from './tools.js'; +const debugLogger = createDebugLogger('MODIFIABLE_TOOL'); + /** * A declarative tool that supports a modify operation. */ @@ -128,13 +131,13 @@ function deleteTempFiles(oldPath: string, newPath: string): void { try { fs.unlinkSync(oldPath); } catch { - console.error(`Error deleting temp diff file: ${oldPath}`); + debugLogger.warn(`Error deleting temp diff file: ${oldPath}`); } try { fs.unlinkSync(newPath); } catch { - console.error(`Error deleting temp diff file: ${newPath}`); + debugLogger.warn(`Error deleting temp diff file: ${newPath}`); } } diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 9fcd0e3d9..1db231d45 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -16,6 +16,9 @@ import { runRipgrep } from '../utils/ripgrepUtils.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('RIPGREP'); /** * Parameters for the GrepTool (Simplified) @@ -157,7 +160,7 @@ class GrepToolInvocation extends BaseToolInvocation< returnDisplay: displayMessage, }; } catch (error) { - console.error(`Error during ripgrep search operation: ${error}`); + debugLogger.error('Error during ripgrep search operation:', error); const errorMessage = getErrorMessage(error); return { llmContent: `Error during grep search operation: ${errorMessage}`, diff --git a/packages/core/src/tools/sdk-control-client-transport.ts b/packages/core/src/tools/sdk-control-client-transport.ts index be2f3099e..f83387d0c 100644 --- a/packages/core/src/tools/sdk-control-client-transport.ts +++ b/packages/core/src/tools/sdk-control-client-transport.ts @@ -18,6 +18,9 @@ */ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MCP_SDK_TRANSPORT'); /** * Callback to send MCP messages to SDK via control plane @@ -43,7 +46,6 @@ export interface SdkControlClientTransportOptions { export class SdkControlClientTransport { private serverName: string; private sendMcpMessage: SendMcpMessageCallback; - private debugMode: boolean; private started = false; // Transport interface callbacks @@ -54,7 +56,8 @@ export class SdkControlClientTransport { constructor(options: SdkControlClientTransportOptions) { this.serverName = options.serverName; this.sendMcpMessage = options.sendMcpMessage; - this.debugMode = options.debugMode ?? false; + // Note: debugMode option is preserved for API compatibility but no longer used + // since debugLogger now always writes to the session logfile } /** @@ -67,12 +70,7 @@ export class SdkControlClientTransport { } this.started = true; - - if (this.debugMode) { - console.error( - `[SdkControlClientTransport] Started for server '${this.serverName}'`, - ); - } + debugLogger.debug(`Started for server '${this.serverName}'`); } /** @@ -88,35 +86,24 @@ export class SdkControlClientTransport { ); } - if (this.debugMode) { - console.error( - `[SdkControlClientTransport] Sending message to '${this.serverName}':`, - JSON.stringify(message), - ); - } + debugLogger.debug( + `Sending message to '${this.serverName}': ${JSON.stringify(message)}`, + ); try { // Send message to SDK and wait for response const response = await this.sendMcpMessage(this.serverName, message); - if (this.debugMode) { - console.error( - `[SdkControlClientTransport] Received response from '${this.serverName}':`, - JSON.stringify(response), - ); - } + debugLogger.debug( + `Received response from '${this.serverName}': ${JSON.stringify(response)}`, + ); // Deliver response via onmessage callback if (this.onmessage) { this.onmessage(response); } } catch (error) { - if (this.debugMode) { - console.error( - `[SdkControlClientTransport] Error sending to '${this.serverName}':`, - error, - ); - } + debugLogger.error(`Error sending to '${this.serverName}': ${error}`); if (this.onerror) { this.onerror(error instanceof Error ? error : new Error(String(error))); @@ -135,12 +122,7 @@ export class SdkControlClientTransport { } this.started = false; - - if (this.debugMode) { - console.error( - `[SdkControlClientTransport] Closed for server '${this.serverName}'`, - ); - } + debugLogger.debug(`Closed for server '${this.serverName}'`); if (this.onclose) { this.onclose(); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d48391b90..e55d03626 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -41,6 +41,9 @@ import { isCommandNeedsPermission, stripShellWrapper, } from '../utils/shell-utils.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SHELL'); export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000; @@ -271,7 +274,7 @@ export class ShellToolInvocation extends BaseToolInvocation< .filter(Boolean); for (const line of pgrepLines) { if (!/^\d+$/.test(line)) { - console.error(`pgrep: ${line}`); + debugLogger.warn(`pgrep: ${line}`); } const pid = Number(line); if (pid !== result.pid) { @@ -280,7 +283,7 @@ export class ShellToolInvocation extends BaseToolInvocation< } } else { if (!signal.aborted) { - console.error('missing pgrep output'); + debugLogger.warn('missing pgrep output'); } } } @@ -372,6 +375,7 @@ export class ShellToolInvocation extends BaseToolInvocation< }, } : {}; + if (summarizeConfig && summarizeConfig[ShellTool.Name]) { const summary = await summarizeToolOutput( llmContent, @@ -573,7 +577,7 @@ export class ShellTool extends BaseDeclarativeTool< const commandCheck = isCommandAllowed(params.command, this.config); if (!commandCheck.allowed) { if (!commandCheck.reason) { - console.error( + debugLogger.error( 'Unexpected: isCommandAllowed returned false without a reason', ); return `Command is not allowed: ${params.command}`; diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index e22a062df..7f327be73 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -156,16 +156,12 @@ describe('SkillTool', () => { new Error('Loading failed'), ); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - new SkillTool(config); + const failedSkillTool = new SkillTool(config); await vi.runAllTimersAsync(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to load skills for Skills tool:', - expect.any(Error), + expect(failedSkillTool.description).toContain( + 'No skills are currently configured', ); - consoleSpy.mockRestore(); }); }); @@ -375,10 +371,6 @@ describe('SkillTool', () => { new Error('Loading failed'), ); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const params: SkillParams = { skill: 'code-review', }; @@ -391,8 +383,6 @@ describe('SkillTool', () => { const llmText = partToString(result.llmContent); expect(llmText).toContain('Failed to load skill'); expect(llmText).toContain('Loading failed'); - - consoleSpy.mockRestore(); }); it('should not require confirmation', async () => { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index f1dc1596b..83ae43b33 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -12,6 +12,9 @@ import type { SkillManager } from '../skills/skill-manager.js'; import type { SkillConfig } from '../skills/types.js'; import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js'; import path from 'path'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('SKILL'); export interface SkillParams { skill: string; @@ -71,7 +74,7 @@ export class SkillTool extends BaseDeclarativeTool { this.availableSkills = await this.skillManager.listSkills(); this.updateDescriptionAndSchema(); } catch (error) { - console.warn('Failed to load skills for Skills tool:', error); + debugLogger.warn('Failed to load skills for Skills tool:', error); this.availableSkills = []; this.updateDescriptionAndSchema(); } finally { @@ -251,7 +254,7 @@ class SkillToolInvocation extends BaseToolInvocation { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[SkillsTool] Error using skill: ${errorMessage}`); + debugLogger.error(`[SkillsTool] Error using skill: ${errorMessage}`); // Log failed skill launch logSkillLaunch( diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts index 40b538801..458b026b6 100644 --- a/packages/core/src/tools/task.test.ts +++ b/packages/core/src/tools/task.test.ts @@ -150,16 +150,12 @@ describe('TaskTool', () => { new Error('Loading failed'), ); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - new TaskTool(config); + const failedTaskTool = new TaskTool(config); await vi.runAllTimersAsync(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to load subagents for Task tool:', - expect.any(Error), + expect(failedTaskTool.description).toContain( + 'No subagents are currently configured', ); - consoleSpy.mockRestore(); }); }); diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index e8fd64d57..e811dde0d 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -34,6 +34,7 @@ import type { SubAgentErrorEvent, SubAgentApprovalRequestEvent, } from '../subagents/subagent-events.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; export interface TaskParams { description: string; @@ -41,6 +42,8 @@ export interface TaskParams { subagent_type: string; } +const debugLogger = createDebugLogger('TASK'); + /** * Task tool that enables primary agents to delegate tasks to specialized subagents. * The tool dynamically loads available subagents and includes them in its description @@ -103,7 +106,7 @@ export class TaskTool extends BaseDeclarativeTool { this.availableSubagents = await this.subagentManager.listSubagents(); this.updateDescriptionAndSchema(); } catch (error) { - console.warn('Failed to load subagents for Task tool:', error); + debugLogger.warn('Failed to load subagents for Task tool:', error); this.availableSubagents = []; this.updateDescriptionAndSchema(); } finally { @@ -550,7 +553,7 @@ class TaskToolInvocation extends BaseToolInvocation { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[TaskTool] Error running subagent: ${errorMessage}`); + debugLogger.error(`[TaskTool] Error running subagent: ${errorMessage}`); const errorDisplay: TaskResultDisplay = { ...this.currentDisplay!, diff --git a/packages/core/src/tools/todoWrite.ts b/packages/core/src/tools/todoWrite.ts index 23deb2603..f99fbccdd 100644 --- a/packages/core/src/tools/todoWrite.ts +++ b/packages/core/src/tools/todoWrite.ts @@ -15,6 +15,9 @@ import * as process from 'process'; import { QWEN_DIR } from '../utils/paths.js'; import type { Config } from '../config/config.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TODO_WRITE'); export interface TodoItem { id: string; @@ -370,7 +373,7 @@ ${todosJson}. Continue on with the tasks at hand if applicable. } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error( + debugLogger.error( `[TodoWriteTool] Error executing todo_write: ${errorMessage}`, ); diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 2bcc3e16a..ceb52e4d9 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -118,10 +118,6 @@ describe('ToolRegistry', () => { } as fs.Stats); config = new Config(baseConfigParams); toolRegistry = new ToolRegistry(config); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(console, 'debug').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); mockMcpClientConnect.mockReset().mockResolvedValue(undefined); mockStdioTransportClose.mockReset(); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a2096a2a5..1db7f7e59 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -22,10 +22,13 @@ import { parse } from 'shell-quote'; import { ToolErrorType } from './tool-error.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import type { EventEmitter } from 'node:events'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; type ToolParams = Record; +const debugLogger = createDebugLogger('TOOL_REGISTRY'); + class DiscoveredToolInvocation extends BaseToolInvocation< ToolParams, ToolResult @@ -198,7 +201,7 @@ export class ToolRegistry { tool = tool.asFullyQualifiedTool(); } else { // Decide on behavior: throw error, log warning, or allow overwrite - console.warn( + debugLogger.warn( `Tool with name "${tool.name}" is already registered. Overwriting.`, ); } @@ -347,8 +350,10 @@ export class ToolRegistry { } if (code !== 0) { - console.error(`Command failed with code ${code}`); - console.error(stderr); + debugLogger.error( + `Tool discovery command failed with code ${code}`, + ); + debugLogger.error(stderr); return reject( new Error(`Tool discovery command failed with exit code ${code}`), ); @@ -381,7 +386,7 @@ export class ToolRegistry { // register each function as a tool for (const func of functions) { if (!func.name) { - console.warn('Discovered a tool with no name. Skipping.'); + debugLogger.warn('Discovered a tool with no name. Skipping.'); continue; } const parameters = @@ -400,7 +405,7 @@ export class ToolRegistry { ); } } catch (e) { - console.error(`Tool discovery command "${discoveryCmd}" failed:`, e); + debugLogger.error(`Tool discovery command "${discoveryCmd}" failed:`, e); throw e; } } @@ -492,7 +497,7 @@ export class ToolRegistry { await this.mcpClientManager.stop(); } catch (error) { // Log but don't throw - cleanup should be best-effort - console.error('Error stopping MCP clients:', error); + debugLogger.error('Error stopping MCP clients:', error); } } } diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index d84d721fb..cfa7b593d 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -36,6 +36,7 @@ describe('WebFetchTool', () => { setApprovalMode: vi.fn(), getProxy: vi.fn(), getGeminiClient: mockGetGeminiClient, + getSessionId: vi.fn(() => 'test-session-id'), } as unknown as Config; }); diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 99aeec3a5..8240770d2 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -24,6 +24,7 @@ import { } from './tools.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; @@ -49,11 +50,14 @@ class WebFetchToolInvocation extends BaseToolInvocation< WebFetchToolParams, ToolResult > { + private readonly debugLogger: DebugLogger; + constructor( private readonly config: Config, params: WebFetchToolParams, ) { super(params); + this.debugLogger = createDebugLogger('WEB_FETCH'); } private async executeDirectFetch(signal: AbortSignal): Promise { @@ -64,22 +68,24 @@ class WebFetchToolInvocation extends BaseToolInvocation< url = url .replace('github.com', 'raw.githubusercontent.com') .replace('/blob/', '/'); - console.debug( + this.debugLogger.debug( `[WebFetchTool] Converted GitHub blob URL to raw URL: ${url}`, ); } try { - console.debug(`[WebFetchTool] Fetching content from: ${url}`); + this.debugLogger.debug(`[WebFetchTool] Fetching content from: ${url}`); const response = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS); if (!response.ok) { const errorMessage = `Request failed with status code ${response.status} ${response.statusText}`; - console.error(`[WebFetchTool] ${errorMessage}`); + this.debugLogger.error(`[WebFetchTool] ${errorMessage}`); throw new Error(errorMessage); } - console.debug(`[WebFetchTool] Successfully fetched content from ${url}`); + this.debugLogger.debug( + `[WebFetchTool] Successfully fetched content from ${url}`, + ); const html = await response.text(); const textContent = convert(html, { wordwrap: false, @@ -89,7 +95,7 @@ class WebFetchToolInvocation extends BaseToolInvocation< ], }).substring(0, MAX_CONTENT_LENGTH); - console.debug( + this.debugLogger.debug( `[WebFetchTool] Converted HTML to text (${textContent.length} characters)`, ); @@ -102,7 +108,7 @@ I have fetched the content from ${this.params.url}. Please use the following con ${textContent} ---`; - console.debug( + this.debugLogger.debug( `[WebFetchTool] Processing content with prompt: "${this.params.prompt}"`, ); @@ -114,7 +120,7 @@ ${textContent} ); const resultText = getResponseText(result) || ''; - console.debug( + this.debugLogger.debug( `[WebFetchTool] Successfully processed content from ${this.params.url}`, ); @@ -125,7 +131,7 @@ ${textContent} } catch (e) { const error = e as Error; const errorMessage = `Error during fetch for ${url}: ${error.message}`; - console.error(`[WebFetchTool] ${errorMessage}`, error); + this.debugLogger.error(`[WebFetchTool] ${errorMessage}`, error); return { llmContent: `Error: ${errorMessage}`, returnDisplay: `Error: ${errorMessage}`, @@ -175,11 +181,11 @@ ${textContent} const isPrivate = isPrivateIp(this.params.url); if (isPrivate) { - console.debug( + this.debugLogger.debug( `[WebFetchTool] Private IP detected for ${this.params.url}, using direct fetch`, ); } else { - console.debug( + this.debugLogger.debug( `[WebFetchTool] Public URL detected for ${this.params.url}, using direct fetch`, ); } diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index cd245128b..f8fcb8c60 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -18,6 +18,7 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; import { GoogleProvider } from './providers/google-provider.js'; @@ -32,6 +33,8 @@ import type { } from './types.js'; import { ToolNames, ToolDisplayNames } from '../tool-names.js'; +const debugLogger = createDebugLogger('WEB_SEARCH'); + class WebSearchToolInvocation extends BaseToolInvocation< WebSearchToolParams, WebSearchToolResult @@ -114,7 +117,7 @@ class WebSearchToolInvocation extends BaseToolInvocation< providers.set(config.type, provider); } } catch (error) { - console.warn(`Failed to create ${config.type} provider:`, error); + debugLogger.warn(`Failed to create ${config.type} provider:`, error); } } @@ -259,7 +262,7 @@ class WebSearchToolInvocation extends BaseToolInvocation< }; } catch (error: unknown) { const errorMessage = `Error during web search: ${getErrorMessage(error)}`; - console.error(errorMessage, error); + debugLogger.error(errorMessage, error); return { llmContent: errorMessage, returnDisplay: 'Error performing web search.', diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index b3d524a92..1ccb7bf0b 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -39,6 +39,9 @@ import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperation } from '../telemetry/metrics.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; import { getLanguageFromFilePath } from '../utils/language-detection.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('WRITE_FILE'); /** * Parameters for the WriteFile tool @@ -197,6 +200,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< async execute(_abortSignal: AbortSignal): Promise { const { file_path, content, ai_proposed_content, modified_by_user } = this.params; + const correctedContentResult = await getCorrectedFileContent( this.config, file_path, @@ -294,12 +298,13 @@ class WriteFileToolInvocation extends BaseToolInvocation< const extension = path.extname(file_path); const operation = isNewFile ? FileOperation.CREATE : FileOperation.UPDATE; + const lineCount = fileContent.split('\n').length; logFileOperation( this.config, new FileOperationEvent( WriteFileTool.Name, operation, - fileContent.split('\n').length, + lineCount, mimetype, extension, programmingLanguage, @@ -341,7 +346,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< // Include stack trace in debug mode for better troubleshooting if (this.config.getDebugMode() && error.stack) { - console.error('Write file error stack:', error.stack); + debugLogger.debug('Write file error stack:', error.stack); } } else if (error instanceof Error) { errorMsg = `Error writing to file: ${error.message}`; diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts new file mode 100644 index 000000000..af7d04f48 --- /dev/null +++ b/packages/core/src/utils/debugLogger.test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createDebugLogger, + isDebugLoggingDegraded, + resetDebugLoggingState, + setDebugLogSession, + type DebugLogSession, +} from './debugLogger.js'; +import { promises as fs } from 'node:fs'; +import { Storage } from '../config/storage.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + mkdir: vi.fn().mockResolvedValue(undefined), + appendFile: vi.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('debugLogger', () => { + const mockSession: DebugLogSession = { + getSessionId: () => 'test-session-123', + }; + + const previousDebugLogFileEnv = process.env['QWEN_DEBUG_LOG_FILE']; + + beforeEach(() => { + process.env['QWEN_DEBUG_LOG_FILE'] = '1'; + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z')); + resetDebugLoggingState(); + setDebugLogSession(mockSession); + }); + + afterEach(() => { + vi.useRealTimers(); + setDebugLogSession(null); + if (previousDebugLogFileEnv === undefined) { + delete process.env['QWEN_DEBUG_LOG_FILE']; + } else { + process.env['QWEN_DEBUG_LOG_FILE'] = previousDebugLogFileEnv; + } + }); + + describe('createDebugLogger', () => { + it('returns no-op logger when session is unset', () => { + setDebugLogSession(null); + const logger = createDebugLogger(); + // Should not throw + logger.debug('test'); + logger.info('test'); + logger.warn('test'); + logger.error('test'); + expect(fs.appendFile).not.toHaveBeenCalled(); + }); + + it('writes debug log with correct format', async () => { + const logger = createDebugLogger(); + logger.debug('Hello world'); + + await vi.runAllTimersAsync(); + + expect(fs.mkdir).toHaveBeenCalledWith(Storage.getGlobalDebugDir(), { + recursive: true, + }); + expect(fs.appendFile).toHaveBeenCalledWith( + Storage.getDebugLogPath('test-session-123'), + '2026-01-24T10:30:00.000Z [DEBUG] Hello world\n', + 'utf8', + ); + }); + + it('writes log with tag when provided', async () => { + const logger = createDebugLogger('STARTUP'); + logger.info('Server started'); + + await vi.runAllTimersAsync(); + + expect(fs.appendFile).toHaveBeenCalledWith( + Storage.getDebugLogPath('test-session-123'), + '2026-01-24T10:30:00.000Z [INFO] [STARTUP] Server started\n', + 'utf8', + ); + }); + + it('writes different log levels correctly', async () => { + const logger = createDebugLogger(); + + logger.debug('debug message'); + logger.info('info message'); + logger.warn('warn message'); + logger.error('error message'); + + await vi.runAllTimersAsync(); + + const calls = vi.mocked(fs.appendFile).mock.calls; + expect(calls[0]?.[1]).toContain('[DEBUG]'); + expect(calls[1]?.[1]).toContain('[INFO]'); + expect(calls[2]?.[1]).toContain('[WARN]'); + expect(calls[3]?.[1]).toContain('[ERROR]'); + }); + + it('formats multiple arguments', async () => { + const logger = createDebugLogger(); + logger.debug('Count:', 42, 'items'); + + await vi.runAllTimersAsync(); + + expect(fs.appendFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Count: 42 items'), + 'utf8', + ); + }); + + it('formats Error objects with stack trace', async () => { + const logger = createDebugLogger(); + const error = new Error('Something went wrong'); + logger.error('Failed:', error); + + await vi.runAllTimersAsync(); + + const call = vi.mocked(fs.appendFile).mock.calls[0]; + expect(call?.[1]).toContain('Failed:'); + expect(call?.[1]).toContain('Error: Something went wrong'); + }); + + it('formats objects using util.inspect', async () => { + const logger = createDebugLogger(); + logger.debug('Data:', { foo: 'bar', count: 123 }); + + await vi.runAllTimersAsync(); + + const call = vi.mocked(fs.appendFile).mock.calls[0]; + expect(call?.[1]).toContain('foo'); + expect(call?.[1]).toContain('bar'); + }); + }); + + describe('isDebugLoggingDegraded', () => { + it('returns false when no failures have occurred', () => { + expect(isDebugLoggingDegraded()).toBe(false); + }); + + it('returns true when mkdir fails', async () => { + vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied')); + + const logger = createDebugLogger(); + logger.debug('test'); + + await vi.runAllTimersAsync(); + + expect(isDebugLoggingDegraded()).toBe(true); + }); + + it('returns true when appendFile fails', async () => { + vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full')); + + const logger = createDebugLogger(); + logger.debug('test'); + + await vi.runAllTimersAsync(); + + expect(isDebugLoggingDegraded()).toBe(true); + }); + + it('stays true after failure even if subsequent writes succeed', async () => { + vi.mocked(fs.appendFile).mockRejectedValueOnce( + new Error('Temporary error'), + ); + + const logger = createDebugLogger(); + logger.debug('first write fails'); + await vi.runAllTimersAsync(); + + expect(isDebugLoggingDegraded()).toBe(true); + + // Reset mock to succeed + vi.mocked(fs.appendFile).mockResolvedValue(undefined); + logger.debug('second write succeeds'); + await vi.runAllTimersAsync(); + + // Should still be degraded + expect(isDebugLoggingDegraded()).toBe(true); + }); + }); + + describe('resetDebugLoggingState', () => { + it('resets the degraded state', async () => { + vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full')); + + const logger = createDebugLogger(); + logger.debug('test'); + await vi.runAllTimersAsync(); + + expect(isDebugLoggingDegraded()).toBe(true); + + resetDebugLoggingState(); + + expect(isDebugLoggingDegraded()).toBe(false); + }); + }); +}); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts new file mode 100644 index 000000000..8c9e60eae --- /dev/null +++ b/packages/core/src/utils/debugLogger.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import util from 'node:util'; +import { Storage } from '../config/storage.js'; + +type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; + +export interface DebugLogSession { + getSessionId: () => string; +} + +export interface DebugLogger { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +let ensureDebugDirPromise: Promise | null = null; +let hasWriteFailure = false; +let globalSession: DebugLogSession | null = null; +const sessionContext = new AsyncLocalStorage(); + +function isDebugLogFileEnabled(): boolean { + const value = process.env['QWEN_DEBUG_LOG_FILE']; + if (!value) return true; + const normalized = value.trim().toLowerCase(); + return !['0', 'false', 'off', 'no'].includes(normalized); +} + +function getActiveSession(): DebugLogSession | null { + return sessionContext.getStore() ?? globalSession; +} + +function ensureDebugDirExists(): Promise { + if (!ensureDebugDirPromise) { + ensureDebugDirPromise = fs + .mkdir(Storage.getGlobalDebugDir(), { recursive: true }) + .then(() => undefined) + .catch(() => { + hasWriteFailure = true; + ensureDebugDirPromise = null; + }); + } + return ensureDebugDirPromise ?? Promise.resolve(); +} + +function formatArgs(args: unknown[]): string { + return args + .map((arg) => { + if (arg instanceof Error) { + return arg.stack ?? `${arg.name}: ${arg.message}`; + } + return arg; + }) + .map((arg) => (typeof arg === 'string' ? arg : util.inspect(arg))) + .join(' '); +} + +/** + * Builds a log line in the format: + * `2026-01-23T06:58:02.011Z [DEBUG] [TAG] message` + * + * Tag is optional. If not provided, format is: + * `2026-01-23T06:58:02.011Z [DEBUG] message` + */ +function buildLogLine(level: LogLevel, message: string, tag?: string): string { + const timestamp = new Date().toISOString(); + const tagPart = tag ? ` [${tag}]` : ''; + return `${timestamp} [${level}]${tagPart} ${message}\n`; +} + +function writeLog( + session: DebugLogSession, + level: LogLevel, + tag: string | undefined, + args: unknown[], +): void { + if (!isDebugLogFileEnabled()) { + return; + } + + const sessionId = session.getSessionId(); + const logFilePath = Storage.getDebugLogPath(sessionId); + const message = formatArgs(args); + const line = buildLogLine(level, message, tag); + + void ensureDebugDirExists() + .then(() => fs.appendFile(logFilePath, line, 'utf8')) + .catch(() => { + hasWriteFailure = true; + }); +} + +/** + * Returns true if any debug log write has failed. + * Used by the UI to show a degraded mode notice on startup. + */ +export function isDebugLoggingDegraded(): boolean { + return hasWriteFailure; +} + +/** + * Resets the write failure tracking state. + * Primarily useful for testing. + */ +export function resetDebugLoggingState(): void { + hasWriteFailure = false; + ensureDebugDirPromise = null; +} + +/** + * Sets the process-wide debug log session used by createDebugLogger(). + * + * This is the default session used when there is no async-local session bound + * via runWithDebugLogSession(). + */ +export function setDebugLogSession( + session: DebugLogSession | null | undefined, +) { + globalSession = session ?? null; +} + +/** + * Runs a function with a session bound to the current async context. + * + * This is optional; createDebugLogger() falls back to the process-wide session + * set via setDebugLogSession(). + */ +export function runWithDebugLogSession( + session: DebugLogSession, + fn: () => T, +): T { + return sessionContext.run(session, fn); +} + +/** + * Creates a debug logger that writes to the current debug log session. + * + * Session resolution order: + * 1) async-local session (runWithDebugLogSession) + * 2) process-wide session (setDebugLogSession) + */ +export function createDebugLogger(tag?: string): DebugLogger { + return { + debug: (...args: unknown[]) => { + const session = getActiveSession(); + if (!session) return; + writeLog(session, 'DEBUG', tag, args); + }, + info: (...args: unknown[]) => { + const session = getActiveSession(); + if (!session) return; + writeLog(session, 'INFO', tag, args); + }, + warn: (...args: unknown[]) => { + const session = getActiveSession(); + if (!session) return; + writeLog(session, 'WARN', tag, args); + }, + error: (...args: unknown[]) => { + const session = getActiveSession(); + if (!session) return; + writeLog(session, 'ERROR', tag, args); + }, + }; +} diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index dd3202b8f..a2ac74680 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -393,15 +393,10 @@ describe('editor utils', () => { }); } - it('should log an error if diff command is not available', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should handle unsupported editor gracefully', async () => { // @ts-expect-error Testing unsupported editor await openDiff('old.txt', 'new.txt', 'foobar', () => {}); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'No diff tool available. Install a supported editor.', - ); + // Function should complete without throwing (logs error to debugLogger) }); describe('onEditorClose callback', () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 78d8b37fb..70f574ab4 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -5,6 +5,9 @@ */ import { execSync, spawn, spawnSync } from 'node:child_process'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('EDITOR'); export type EditorType = | 'vscode' @@ -174,7 +177,7 @@ export async function openDiff( ): Promise { const diffCommand = getDiffCommand(oldPath, newPath, editor); if (!diffCommand) { - console.error('No diff tool available. Install a supported editor.'); + debugLogger.error('No diff tool available. Install a supported editor.'); return; } @@ -217,7 +220,7 @@ export async function openDiff( }); }); } catch (error) { - console.error(error); + debugLogger.error(error); throw error; } } diff --git a/packages/core/src/utils/errorReporting.test.ts b/packages/core/src/utils/errorReporting.test.ts index 3a9f92baa..585a0b976 100644 --- a/packages/core/src/utils/errorReporting.test.ts +++ b/packages/core/src/utils/errorReporting.test.ts @@ -5,58 +5,48 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import { reportError } from './errorReporting.js'; -// Use a type alias for SpyInstance as it's not directly exported -type SpyInstance = ReturnType; +const debugLoggerSpy = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +// Mock the debugLogger +vi.mock('./debugLogger.js', () => ({ + createDebugLogger: () => ({ + error: debugLoggerSpy.error, + warn: debugLoggerSpy.warn, + info: debugLoggerSpy.info, + debug: debugLoggerSpy.debug, + }), +})); describe('reportError', () => { - let consoleErrorSpy: SpyInstance; - let testDir: string; - const MOCK_TIMESTAMP = '2025-01-01T00-00-00-000Z'; - - beforeEach(async () => { - // Create a temporary directory for logs - testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-report-test-')); + beforeEach(() => { vi.resetAllMocks(); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(Date.prototype, 'toISOString').mockReturnValue(MOCK_TIMESTAMP); }); - afterEach(async () => { + afterEach(() => { vi.restoreAllMocks(); - // Clean up the temporary directory - await fs.rm(testDir, { recursive: true, force: true }); }); - const getExpectedReportPath = (type: string) => - path.join(testDir, `qwen-client-error-${type}-${MOCK_TIMESTAMP}.json`); - - it('should generate a report and log the path', async () => { + it('should not throw when called with a standard error', async () => { const error = new Error('Test error'); error.stack = 'Test stack'; const baseMessage = 'An error occurred.'; const context = { data: 'test context' }; const type = 'test-type'; - const expectedReportPath = getExpectedReportPath(type); - await reportError(error, baseMessage, context, type, testDir); - - // Verify the file was written - const reportContent = await fs.readFile(expectedReportPath, 'utf-8'); - const parsedReport = JSON.parse(reportContent); - - expect(parsedReport).toEqual({ - error: { message: 'Test error', stack: 'Test stack' }, - context, - }); - - // Verify the console log - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Full report available at: ${expectedReportPath}`, + await expect( + reportError(error, baseMessage, context, type), + ).resolves.not.toThrow(); + expect(debugLoggerSpy.error).toHaveBeenCalled(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [${type}]`, + expect.any(String), ); }); @@ -64,19 +54,13 @@ describe('reportError', () => { const error = { message: 'Test plain object error' }; const baseMessage = 'Another error.'; const type = 'general'; - const expectedReportPath = getExpectedReportPath(type); - await reportError(error, baseMessage, undefined, type, testDir); - - const reportContent = await fs.readFile(expectedReportPath, 'utf-8'); - const parsedReport = JSON.parse(reportContent); - - expect(parsedReport).toEqual({ - error: { message: 'Test plain object error' }, - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Full report available at: ${expectedReportPath}`, + await expect( + reportError(error, baseMessage, undefined, type), + ).resolves.not.toThrow(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [${type}]`, + expect.any(String), ); }); @@ -84,52 +68,21 @@ describe('reportError', () => { const error = 'Just a string error'; const baseMessage = 'String error occurred.'; const type = 'general'; - const expectedReportPath = getExpectedReportPath(type); - await reportError(error, baseMessage, undefined, type, testDir); - - const reportContent = await fs.readFile(expectedReportPath, 'utf-8'); - const parsedReport = JSON.parse(reportContent); - - expect(parsedReport).toEqual({ - error: { message: 'Just a string error' }, - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Full report available at: ${expectedReportPath}`, + await expect( + reportError(error, baseMessage, undefined, type), + ).resolves.not.toThrow(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [${type}]`, + expect.any(String), ); }); - it('should log fallback message if writing report fails', async () => { - const error = new Error('Main error'); - const baseMessage = 'Failed operation.'; - const context = ['some context']; - const type = 'general'; - const nonExistentDir = path.join(testDir, 'non-existent-dir'); - - await reportError(error, baseMessage, context, type, nonExistentDir); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Additionally, failed to write detailed error report:`, - expect.any(Error), // The actual write error - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Original error that triggered report generation:', - error, - ); - expect(consoleErrorSpy).toHaveBeenCalledWith('Original context:', context); - }); - it('should handle stringification failure of report content (e.g. BigInt in context)', async () => { const error = new Error('Main error'); error.stack = 'Main stack'; const baseMessage = 'Failed operation with BigInt.'; const context = { a: BigInt(1) }; // BigInt cannot be stringified by JSON.stringify - const type = 'bigint-fail'; - const stringifyError = new TypeError( - 'Do not know how to serialize a BigInt', - ); - const expectedMinimalReportPath = getExpectedReportPath(type); // Simulate JSON.stringify throwing an error for the full report const originalJsonStringify = JSON.stringify; @@ -138,35 +91,23 @@ describe('reportError', () => { callCount++; if (callCount === 1) { // First call is for the full report content - throw stringifyError; + throw new TypeError('Do not know how to serialize a BigInt'); } // Subsequent calls (for minimal report) should succeed return originalJsonStringify(value, replacer, space); }); - await reportError(error, baseMessage, context, type, testDir); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Could not stringify report content (likely due to context):`, - stringifyError, - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Original error that triggered report generation:', + await expect( + reportError(error, baseMessage, context, 'bigint-fail'), + ).resolves.not.toThrow(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [bigint-fail] Could not stringify report content (likely due to context):`, + expect.any(TypeError), error, ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Original context could not be stringified or included in report.', - ); - - // Check that it writes a minimal report - const reportContent = await fs.readFile(expectedMinimalReportPath, 'utf-8'); - const parsedReport = JSON.parse(reportContent); - expect(parsedReport).toEqual({ - error: { message: error.message, stack: error.stack }, - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Partial report (excluding context) available at: ${expectedMinimalReportPath}`, + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [bigint-fail]`, + expect.any(String), ); }); @@ -175,19 +116,13 @@ describe('reportError', () => { error.stack = 'No context stack'; const baseMessage = 'Simple error.'; const type = 'general'; - const expectedReportPath = getExpectedReportPath(type); - await reportError(error, baseMessage, undefined, type, testDir); - - const reportContent = await fs.readFile(expectedReportPath, 'utf-8'); - const parsedReport = JSON.parse(reportContent); - - expect(parsedReport).toEqual({ - error: { message: 'Error without context', stack: 'No context stack' }, - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - `${baseMessage} Full report available at: ${expectedReportPath}`, + await expect( + reportError(error, baseMessage, undefined, type), + ).resolves.not.toThrow(); + expect(debugLoggerSpy.error).toHaveBeenCalledWith( + `${baseMessage} [${type}]`, + expect.any(String), ); }); }); diff --git a/packages/core/src/utils/errorReporting.ts b/packages/core/src/utils/errorReporting.ts index 3e137ef61..fd162b5a6 100644 --- a/packages/core/src/utils/errorReporting.ts +++ b/packages/core/src/utils/errorReporting.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import type { Content } from '@google/genai'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('ERROR_REPORT'); interface ErrorReportData { error: { message: string; stack?: string } | { message: string }; @@ -16,23 +16,18 @@ interface ErrorReportData { } /** - * Generates an error report, writes it to a temporary file, and logs information to console.error. + * Generates an error report and writes it to the debug log. * @param error The error object. + * @param baseMessage The base message describing the error context. * @param context The relevant context (e.g., chat history, request contents). * @param type A string to identify the type of error (e.g., 'startChat', 'generateJson-api'). - * @param baseMessage The initial message to log to console.error before the report path. */ export async function reportError( error: Error | unknown, baseMessage: string, context?: Content[] | Record | unknown[], type = 'general', - reportingDir = os.tmpdir(), // for testing ): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const reportFileName = `qwen-client-error-${type}-${timestamp}.json`; - const reportPath = path.join(reportingDir, reportFileName); - let errorToReport: { message: string; stack?: string }; if (error instanceof Error) { errorToReport = { message: error.message, stack: error.stack }; @@ -54,65 +49,32 @@ export async function reportError( reportContent.context = context; } + const reportLabel = `${baseMessage} [${type}]`; let stringifiedReportContent: string; try { stringifiedReportContent = JSON.stringify(reportContent, null, 2); } catch (stringifyError) { // This can happen if context contains something like BigInt - console.error( - `${baseMessage} Could not stringify report content (likely due to context):`, + debugLogger.error( + `${reportLabel} Could not stringify report content (likely due to context):`, stringifyError, + error, ); - console.error('Original error that triggered report generation:', error); - if (context) { - console.error( - 'Original context could not be stringified or included in report.', - ); - } // Fallback: try to report only the error if context was the issue try { const minimalReportContent = { error: errorToReport }; stringifiedReportContent = JSON.stringify(minimalReportContent, null, 2); - // Still try to write the minimal report - await fs.writeFile(reportPath, stringifiedReportContent); - console.error( - `${baseMessage} Partial report (excluding context) available at: ${reportPath}`, - ); - } catch (minimalWriteError) { - console.error( - `${baseMessage} Failed to write even a minimal error report:`, - minimalWriteError, + debugLogger.error(reportLabel, stringifiedReportContent); + } catch (minimalStringifyError) { + debugLogger.error( + `${reportLabel} Failed to stringify minimal error report:`, + minimalStringifyError, + error, ); } return; } - try { - await fs.writeFile(reportPath, stringifiedReportContent); - console.error(`${baseMessage} Full report available at: ${reportPath}`); - } catch (writeError) { - console.error( - `${baseMessage} Additionally, failed to write detailed error report:`, - writeError, - ); - // Log the original error as a fallback if report writing fails - console.error('Original error that triggered report generation:', error); - if (context) { - // Context was stringifiable, but writing the file failed. - // We already have stringifiedReportContent, but it might be too large for console. - // So, we try to log the original context object, and if that fails, its stringified version (truncated). - try { - console.error('Original context:', context); - } catch { - try { - console.error( - 'Original context (stringified, truncated):', - JSON.stringify(context).substring(0, 1000), - ); - } catch { - console.error('Original context could not be logged or stringified.'); - } - } - } - } + // Write to debug log instead of separate file + debugLogger.error(reportLabel, stringifiedReportContent); } diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 76b8acc09..3e4124d18 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -12,6 +12,9 @@ import mime from 'mime/lite'; import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; import type { Config } from '../config/config.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('FILE_UTILS'); // Default values for encoding and separator format export const DEFAULT_ENCODING: BufferEncoding = 'utf-8'; @@ -218,7 +221,7 @@ export async function isBinaryFile(filePath: string): Promise { // If >30% non-printable characters, consider it binary return nonPrintableCount / bytesRead > 0.3; } catch (error) { - console.warn( + debugLogger.warn( `Failed to check if file is binary: ${filePath}`, error instanceof Error ? error.message : String(error), ); @@ -228,7 +231,7 @@ export async function isBinaryFile(filePath: string): Promise { try { await fh.close(); } catch (closeError) { - console.warn( + debugLogger.warn( `Failed to close file handle for: ${filePath}`, closeError instanceof Error ? closeError.message : String(closeError), ); diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index db356439d..3b40f1984 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -11,6 +11,9 @@ import { getErrorMessage, isNodeError } from './errors.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('FOLDER_STRUCTURE'); const MAX_ITEMS = 20; const TRUNCATION_INDICATOR = '...'; @@ -104,7 +107,7 @@ async function readFullStructure( isNodeError(error) && (error.code === 'EACCES' || error.code === 'ENOENT') ) { - console.warn( + debugLogger.warn( `Warning: Could not read directory ${currentPath}: ${error.message}`, ); if (currentPath === rootPath && error.code === 'ENOENT') { @@ -324,7 +327,10 @@ export async function getFolderStructure( // 3. Build the final output string return `Showing up to ${mergedOptions.maxItems} items:\n\n${resolvedPath}${path.sep}\n${structureLines.join('\n')}`; } catch (error: unknown) { - console.error(`Error getting folder structure for ${resolvedPath}:`, error); + debugLogger.error( + `Error getting folder structure for ${resolvedPath}:`, + error, + ); return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`; } } diff --git a/packages/core/src/utils/installationManager.test.ts b/packages/core/src/utils/installationManager.test.ts index 1e6263667..09575ae41 100644 --- a/packages/core/src/utils/installationManager.test.ts +++ b/packages/core/src/utils/installationManager.test.ts @@ -90,14 +90,10 @@ describe('InstallationManager', () => { readSpy.mockImplementationOnce(() => { throw new Error('Read error'); }); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); const id = installationManager.getInstallationId(); expect(id).toBe('123456789'); - expect(consoleErrorSpy).toHaveBeenCalled(); }); }); }); diff --git a/packages/core/src/utils/installationManager.ts b/packages/core/src/utils/installationManager.ts index 154e3b759..c81355bf4 100644 --- a/packages/core/src/utils/installationManager.ts +++ b/packages/core/src/utils/installationManager.ts @@ -8,6 +8,9 @@ import * as fs from 'node:fs'; import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; import { Storage } from '../config/storage.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('INSTALLATION'); export class InstallationManager { private getInstallationIdPath(): string { @@ -48,7 +51,7 @@ export class InstallationManager { return installationId; } catch (error) { - console.error( + debugLogger.error( 'Error accessing installation ID file, generating ephemeral ID:', error, ); diff --git a/packages/core/src/utils/jsonl-utils.ts b/packages/core/src/utils/jsonl-utils.ts index d0771f1b7..7c74282ef 100644 --- a/packages/core/src/utils/jsonl-utils.ts +++ b/packages/core/src/utils/jsonl-utils.ts @@ -25,6 +25,9 @@ import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { Mutex } from 'async-mutex'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('JSONL'); /** * A map of file paths to mutexes for preventing concurrent writes. @@ -68,7 +71,7 @@ export async function readLines( return results; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error( + debugLogger.error( `Error reading first ${count} lines from ${filePath}:`, error, ); @@ -100,7 +103,7 @@ export async function read(filePath: string): Promise { return results; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(`Error reading ${filePath}:`, error); + debugLogger.error(`Error reading ${filePath}:`, error); } return []; } @@ -174,7 +177,7 @@ export async function countLines(filePath: string): Promise { return count; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(`Error counting lines in ${filePath}:`, error); + debugLogger.error(`Error counting lines in ${filePath}:`, error); } return 0; } diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index f66418cdc..8842e0311 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -85,7 +85,6 @@ describe('loadServerHierarchicalMemory', () => { const { fileCount } = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], false, // untrusted @@ -109,7 +108,6 @@ describe('loadServerHierarchicalMemory', () => { const { fileCount, memoryContent } = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], false, // untrusted @@ -124,7 +122,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -145,7 +142,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -169,7 +165,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -197,7 +192,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -222,7 +216,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -248,7 +241,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -273,7 +265,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -311,7 +302,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -333,7 +323,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [], - false, new FileDiscoveryService(projectRoot), [extensionFilePath], DEFAULT_FOLDER_TRUST, @@ -357,7 +346,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, [includedDir], - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -389,7 +377,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, createdFiles.map((f) => path.dirname(f)), - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, @@ -422,7 +409,6 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( parentDir, [childDir, parentDir], // Deliberately include duplicates - false, new FileDiscoveryService(projectRoot), [], DEFAULT_FOLDER_TRUST, diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index ab45ef7e2..2a891e84a 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -12,19 +12,9 @@ import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; import { QWEN_DIR } from './paths.js'; +import { createDebugLogger } from './debugLogger.js'; -// Simple console logger, similar to the one previously in CLI's config.ts -// TODO: Integrate with a more robust server-side logger if available/appropriate. -const logger = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - debug: (...args: any[]) => - console.debug('[DEBUG] [MemoryDiscovery]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warn: (...args: any[]) => console.warn('[WARN] [MemoryDiscovery]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: (...args: any[]) => - console.error('[ERROR] [MemoryDiscovery]', ...args), -}; +const logger = createDebugLogger('MEMORY_DISCOVERY'); interface GeminiFileContent { filePath: string; @@ -79,7 +69,6 @@ async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, includeDirectoriesToReadGemini: readonly string[], userHomePath: string, - debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, @@ -100,7 +89,6 @@ async function getGeminiMdFilePathsInternal( getGeminiMdFilePathsInternalForEachDir( dir, userHomePath, - debugMode, fileService, extensionContextFilePaths, folderTrust, @@ -128,7 +116,6 @@ async function getGeminiMdFilePathsInternal( async function getGeminiMdFilePathsInternalForEachDir( dir: string, userHomePath: string, - debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, @@ -148,10 +135,9 @@ async function getGeminiMdFilePathsInternalForEachDir( try { await fs.access(globalMemoryPath, fsSync.constants.R_OK); allPaths.add(globalMemoryPath); - if (debugMode) - logger.debug( - `Found readable global ${geminiMdFilename}: ${globalMemoryPath}`, - ); + logger.debug( + `Found readable global ${geminiMdFilename}: ${globalMemoryPath}`, + ); } catch { // It's okay if it's not found. } @@ -167,10 +153,9 @@ async function getGeminiMdFilePathsInternalForEachDir( await fs.access(homeContextPath, fsSync.constants.R_OK); if (homeContextPath !== globalMemoryPath) { allPaths.add(homeContextPath); - if (debugMode) - logger.debug( - `Found readable home ${geminiMdFilename}: ${homeContextPath}`, - ); + logger.debug( + `Found readable home ${geminiMdFilename}: ${homeContextPath}`, + ); } } catch { // Not found, which is okay @@ -179,14 +164,12 @@ async function getGeminiMdFilePathsInternalForEachDir( // FIX: Only perform the workspace search (upward scan from CWD to project root) // if a valid currentWorkingDirectory is provided and it's not the home directory. const resolvedCwd = path.resolve(dir); - if (debugMode) - logger.debug( - `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, - ); + logger.debug( + `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, + ); const projectRoot = await findProjectRoot(resolvedCwd); - if (debugMode) - logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); + logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); const upwardPaths: string[] = []; let currentDir = resolvedCwd; @@ -226,18 +209,16 @@ async function getGeminiMdFilePathsInternalForEachDir( const finalPaths = Array.from(allPaths); - if (debugMode) - logger.debug( - `Final ordered ${getAllGeminiMdFilenames()} paths to read: ${JSON.stringify( - finalPaths, - )}`, - ); + logger.debug( + `Final ordered ${getAllGeminiMdFilenames()} paths to read: ${JSON.stringify( + finalPaths, + )}`, + ); return finalPaths; } async function readGeminiMdFiles( filePaths: string[], - debugMode: boolean, importFormat: 'flat' | 'tree' = 'tree', ): Promise { // Process files in parallel with concurrency limit to prevent EMFILE errors @@ -255,15 +236,13 @@ async function readGeminiMdFiles( const processedResult = await processImports( content, path.dirname(filePath), - debugMode, undefined, undefined, importFormat, ); - if (debugMode) - logger.debug( - `Successfully read and processed imports: ${filePath} (Length: ${processedResult.content.length})`, - ); + logger.debug( + `Successfully read and processed imports: ${filePath} (Length: ${processedResult.content.length})`, + ); return { filePath, content: processedResult.content }; } catch (error: unknown) { @@ -276,7 +255,7 @@ async function readGeminiMdFiles( `Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`, ); } - if (debugMode) logger.debug(`Failed to read: ${filePath}`); + logger.debug(`Failed to read: ${filePath}`); return { filePath, content: null }; // Still include it with null content } }, @@ -333,16 +312,14 @@ export interface LoadServerHierarchicalMemoryResponse { export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, includeDirectoriesToReadGemini: readonly string[], - debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, importFormat: 'flat' | 'tree' = 'tree', ): Promise { - if (debugMode) - logger.debug( - `Loading server hierarchical memory for CWD: ${currentWorkingDirectory} (importFormat: ${importFormat})`, - ); + logger.debug( + `Loading server hierarchical memory for CWD: ${currentWorkingDirectory} (importFormat: ${importFormat})`, + ); // For the server, homedir() refers to the server process's home. // This is consistent with how MemoryTool already finds the global path. @@ -351,20 +328,15 @@ export async function loadServerHierarchicalMemory( currentWorkingDirectory, includeDirectoriesToReadGemini, userHomePath, - debugMode, fileService, extensionContextFilePaths, folderTrust, ); if (filePaths.length === 0) { - if (debugMode) logger.debug('No QWEN.md files found in hierarchy.'); + logger.debug('No QWEN.md files found in hierarchy.'); return { memoryContent: '', fileCount: 0 }; } - const contentsWithPaths = await readGeminiMdFiles( - filePaths, - debugMode, - importFormat, - ); + const contentsWithPaths = await readGeminiMdFiles(filePaths, importFormat); // Pass CWD for relative path display in concatenated content const combinedInstructions = concatenateInstructions( contentsWithPaths, @@ -378,14 +350,6 @@ export async function loadServerHierarchicalMemory( memoryFilenames.has(path.basename(item.filePath)), ).length; - if (debugMode) - logger.debug( - `Combined instructions length: ${combinedInstructions.length}`, - ); - if (debugMode && combinedInstructions.length > 0) - logger.debug( - `Combined instructions (snippet): ${combinedInstructions.substring(0, 500)}...`, - ); return { memoryContent: combinedInstructions, fileCount, // Only count the context files diff --git a/packages/core/src/utils/memoryImportProcessor.test.ts b/packages/core/src/utils/memoryImportProcessor.test.ts index c7d23da0d..4dd7e3b02 100644 --- a/packages/core/src/utils/memoryImportProcessor.test.ts +++ b/packages/core/src/utils/memoryImportProcessor.test.ts @@ -119,7 +119,7 @@ describe('memoryImportProcessor', () => { mockedFs.access.mockResolvedValue(undefined); mockedFs.readFile.mockResolvedValue(importedContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Use marked to find HTML comments (import markers) const comments = findMarkdownComments(result.content); @@ -153,7 +153,7 @@ describe('memoryImportProcessor', () => { mockedFs.access.mockResolvedValue(undefined); mockedFs.readFile.mockResolvedValue(importedContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Use marked to find import comments const comments = findMarkdownComments(result.content); @@ -200,7 +200,7 @@ describe('memoryImportProcessor', () => { currentFile: testPath('test', 'path', 'main.md'), // Simulate we're processing main.md }; - const result = await processImports(content, basePath, true, importState); + const result = await processImports(content, basePath, importState); // The circular import should be detected when processing the nested import expect(result.content).toContain( @@ -219,7 +219,7 @@ describe('memoryImportProcessor', () => { }), ); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Content should be preserved as-is when file doesn't exist expect(result.content).toBe(content); @@ -236,16 +236,13 @@ describe('memoryImportProcessor', () => { Object.assign(new Error('Permission denied'), { code: 'EACCES' }), ); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Should show error comment for non-ENOENT errors expect(result.content).toContain( '', ); - expect(console.error).toHaveBeenCalledWith( - '[ERROR] [ImportProcessor]', - 'Failed to import ./permission-denied.md: Permission denied', - ); + expect(console.error).not.toHaveBeenCalled(); }); it('should respect max depth limit', async () => { @@ -262,12 +259,9 @@ describe('memoryImportProcessor', () => { currentDepth: 1, }; - const result = await processImports(content, basePath, true, importState); + const result = await processImports(content, basePath, importState); - expect(console.warn).toHaveBeenCalledWith( - '[WARN] [ImportProcessor]', - 'Maximum import depth (1) reached. Stopping import processing.', - ); + expect(console.warn).not.toHaveBeenCalled(); expect(result.content).toBe(content); }); @@ -282,7 +276,7 @@ describe('memoryImportProcessor', () => { .mockResolvedValueOnce(nestedContent) .mockResolvedValueOnce(innerContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); expect(result.content).toContain(''); expect(result.content).toContain(''); @@ -297,7 +291,7 @@ describe('memoryImportProcessor', () => { mockedFs.access.mockResolvedValue(undefined); mockedFs.readFile.mockResolvedValue(importedContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); expect(result.content).toContain( '', @@ -315,7 +309,7 @@ describe('memoryImportProcessor', () => { .mockResolvedValueOnce(firstContent) .mockResolvedValueOnce(secondContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); expect(result.content).toContain(''); expect(result.content).toContain(''); @@ -343,7 +337,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -383,7 +376,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -432,7 +424,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -458,7 +449,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -488,7 +478,7 @@ describe('memoryImportProcessor', () => { ); // 中文路径 doesn't exist mockedFs.readFile.mockResolvedValue(importedContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Should import valid.md expect(result.content).toContain(importedContent); @@ -509,7 +499,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -533,7 +522,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -548,7 +536,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, ); @@ -571,7 +558,7 @@ describe('memoryImportProcessor', () => { .mockResolvedValueOnce(simpleContent) .mockResolvedValueOnce(innerContent); - const result = await processImports(content, basePath, true); + const result = await processImports(content, basePath); // Use marked to find and validate import comments const comments = findMarkdownComments(result.content); @@ -639,7 +626,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, 'flat', @@ -715,7 +701,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, // followImports undefined, // allowedPaths projectRoot, 'flat', // outputFormat @@ -747,7 +732,6 @@ describe('memoryImportProcessor', () => { const result = await processImports( content, basePath, - true, undefined, projectRoot, 'flat', diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index 7b535969d..0b48a3a1b 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -8,18 +8,9 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { isSubpath } from './paths.js'; import { marked, type Token } from 'marked'; +import { createDebugLogger } from './debugLogger.js'; -// Simple console logger for import processing -const logger = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - debug: (...args: any[]) => - console.debug('[DEBUG] [ImportProcessor]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warn: (...args: any[]) => console.warn('[WARN] [ImportProcessor]', ...args), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: (...args: any[]) => - console.error('[ERROR] [ImportProcessor]', ...args), -}; +const logger = createDebugLogger('IMPORT_PROCESSOR'); /** * Interface for tracking import processing state to prevent circular imports @@ -201,7 +192,6 @@ function findCodeRegions(content: string): Array<[number, number]> { * Supports @path/to/file syntax for importing content from other files * @param content - The content to process for imports * @param basePath - The directory path where the current file is located - * @param debugMode - Whether to enable debug logging * @param importState - State tracking for circular import prevention * @param projectRoot - The project root directory for allowed directories * @param importFormat - The format of the import tree @@ -210,7 +200,6 @@ function findCodeRegions(content: string): Array<[number, number]> { export async function processImports( content: string, basePath: string, - debugMode: boolean = false, importState: ImportState = { processedFiles: new Set(), maxDepth: 5, @@ -224,11 +213,9 @@ export async function processImports( } if (importState.currentDepth >= importState.maxDepth) { - if (debugMode) { - logger.warn( - `Maximum import depth (${importState.maxDepth}) reached. Stopping import processing.`, - ); - } + logger.warn( + `Maximum import depth (${importState.maxDepth}) reached. Stopping import processing.`, + ); return { content, importTree: { path: importState.currentFile || 'unknown' }, @@ -306,7 +293,7 @@ export async function processImports( } catch (error) { // If file doesn't exist, silently skip this import (it's not a real import) // Only log warnings for other types of errors - if (!isFileNotFoundError(error) && debugMode) { + if (!isFileNotFoundError(error)) { logger.warn( `Failed to import ${fullPath}: ${hasMessage(error) ? error.message : 'Unknown error'}`, ); @@ -377,7 +364,6 @@ export async function processImports( const imported = await processImports( fileContent, path.dirname(fullPath), - debugMode, newImportState, projectRoot, importFormat, diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index 9570179c9..b69fe011d 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -9,6 +9,9 @@ import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { GeminiChat } from '../core/geminiChat.js'; import { isFunctionResponse } from './messageInspectors.js'; import type { Config } from '../config/config.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('NEXT_SPEAKER'); const CHECK_PROMPT = `Analyze *only* the content and structure of your immediately preceding response (your last turn in the conversation history). Based *strictly* on that response, determine who should logically speak next: the 'user' or the 'model' (you). **Decision Rules (apply in order):** @@ -126,7 +129,7 @@ export async function checkNextSpeaker( } return null; } catch (error) { - console.warn( + debugLogger.warn( 'Failed to talk to Gemini endpoint when seeing if conversation should continue.', error, ); diff --git a/packages/core/src/utils/openaiLogger.test.ts b/packages/core/src/utils/openaiLogger.test.ts index 17a074865..9d3387e4b 100644 --- a/packages/core/src/utils/openaiLogger.test.ts +++ b/packages/core/src/utils/openaiLogger.test.ts @@ -12,11 +12,15 @@ import { OpenAILogger } from './openaiLogger.js'; describe('OpenAILogger', () => { let originalCwd: string; + let originalHome: string | undefined; let testTempDir: string; const createdDirs: string[] = []; + const testHomeDir = path.join(os.tmpdir(), 'openai-logger-home'); beforeEach(() => { originalCwd = process.cwd(); + originalHome = process.env['HOME']; + process.env['HOME'] = testHomeDir; testTempDir = path.join(os.tmpdir(), `openai-logger-test-${Date.now()}`); createdDirs.length = 0; // Clear array }); @@ -41,6 +45,11 @@ describe('OpenAILogger', () => { await Promise.all(cleanupPromises); process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = originalHome; + } }); describe('constructor', () => { diff --git a/packages/core/src/utils/openaiLogger.ts b/packages/core/src/utils/openaiLogger.ts index a4fc41ec8..c6a56ee0a 100644 --- a/packages/core/src/utils/openaiLogger.ts +++ b/packages/core/src/utils/openaiLogger.ts @@ -8,6 +8,9 @@ import * as path from 'node:path'; import { promises as fs } from 'node:fs'; import { v4 as uuidv4 } from 'uuid'; import * as os from 'os'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('OPENAI_LOGGER'); /** * Logger specifically for OpenAI API requests and responses @@ -47,7 +50,7 @@ export class OpenAILogger { await fs.mkdir(this.logDir, { recursive: true }); this.initialized = true; } catch (error) { - console.error('Failed to initialize OpenAI logger:', error); + debugLogger.error('Failed to initialize OpenAI logger:', error); throw new Error(`Failed to initialize OpenAI logger: ${error}`); } } @@ -95,7 +98,7 @@ export class OpenAILogger { await fs.writeFile(filePath, JSON.stringify(logData, null, 2), 'utf-8'); return filePath; } catch (writeError) { - console.error('Failed to write OpenAI log file:', writeError); + debugLogger.error('Failed to write OpenAI log file:', writeError); throw new Error(`Failed to write OpenAI log file: ${writeError}`); } } @@ -123,7 +126,7 @@ export class OpenAILogger { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } - console.error('Failed to read OpenAI log directory:', error); + debugLogger.error('Failed to read OpenAI log directory:', error); return []; } } @@ -138,7 +141,7 @@ export class OpenAILogger { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { - console.error(`Failed to read log file ${filePath}:`, error); + debugLogger.error(`Failed to read log file ${filePath}:`, error); throw new Error(`Failed to read log file: ${error}`); } } diff --git a/packages/core/src/utils/request-tokenizer/imageTokenizer.ts b/packages/core/src/utils/request-tokenizer/imageTokenizer.ts index b55c6b9ec..76933a0c8 100644 --- a/packages/core/src/utils/request-tokenizer/imageTokenizer.ts +++ b/packages/core/src/utils/request-tokenizer/imageTokenizer.ts @@ -6,6 +6,9 @@ import type { ImageMetadata } from './types.js'; import { isSupportedImageMimeType } from './supportedImageFormats.js'; +import { createDebugLogger } from '../debugLogger.js'; + +const debugLogger = createDebugLogger('IMAGE_TOKENIZER'); /** * Image tokenizer for calculating image tokens based on dimensions @@ -44,7 +47,7 @@ export class ImageTokenizer { try { // Check if the MIME type is supported if (!isSupportedImageMimeType(mimeType)) { - console.warn(`Unsupported image format: ${mimeType}`); + debugLogger.warn(`Unsupported image format: ${mimeType}`); // Return default metadata for unsupported formats return { width: 512, @@ -65,7 +68,7 @@ export class ImageTokenizer { dataSize: buffer.length, }; } catch (error) { - console.warn('Failed to extract image metadata:', error); + debugLogger.warn('Failed to extract image metadata:', error); // Return default metadata for fallback return { width: 512, @@ -320,7 +323,7 @@ export class ImageTokenizer { const metadata = await this.extractImageMetadata(data, mimeType); results.push(this.calculateTokens(metadata)); } catch (error) { - console.warn('Error calculating tokens for image:', error); + debugLogger.warn('Error calculating tokens for image:', error); // Return minimum tokens as fallback results.push( ImageTokenizer.MIN_TOKENS_PER_IMAGE + @@ -499,7 +502,7 @@ export class ImageTokenizer { } // Fallback: return default dimensions if we can't parse the structure - console.warn('Could not extract HEIC dimensions, using default'); + debugLogger.warn('Could not extract HEIC dimensions, using default'); return { width: 512, height: 512 }; } } diff --git a/packages/core/src/utils/request-tokenizer/requestTokenizer.ts b/packages/core/src/utils/request-tokenizer/requestTokenizer.ts index ace8d10f6..95ebd9a67 100644 --- a/packages/core/src/utils/request-tokenizer/requestTokenizer.ts +++ b/packages/core/src/utils/request-tokenizer/requestTokenizer.ts @@ -13,6 +13,9 @@ import type { import type { TokenCalculationResult } from './types.js'; import { TextTokenizer } from './textTokenizer.js'; import { ImageTokenizer } from './imageTokenizer.js'; +import { createDebugLogger } from '../debugLogger.js'; + +const debugLogger = createDebugLogger('TOKENIZER'); /** * Simple request token estimator that handles text and image content serially @@ -77,7 +80,7 @@ export class RequestTokenizer { processingTime, }; } catch (error) { - console.error('Error calculating tokens:', error); + debugLogger.error('Error calculating tokens:', error); // Fallback calculation const fallbackTokens = this.calculateFallbackTokens(request); @@ -105,7 +108,7 @@ export class RequestTokenizer { // Avoid per-part rounding inflation by estimating once on the combined text. return await this.textTokenizer.calculateTokens(textContents.join('')); } catch (error) { - console.warn('Error calculating text tokens:', error); + debugLogger.warn('Error calculating text tokens:', error); // Fallback: character-based estimation const totalChars = textContents.join('').length; return Math.ceil(totalChars / 4); @@ -125,7 +128,7 @@ export class RequestTokenizer { await this.imageTokenizer.calculateTokensBatch(imageContents); return tokenCounts.reduce((sum, count) => sum + count, 0); } catch (error) { - console.warn('Error calculating image tokens:', error); + debugLogger.warn('Error calculating image tokens:', error); // Fallback: minimum tokens per image return imageContents.length * 6; // 4 image tokens + 2 special tokens as minimum } @@ -151,7 +154,7 @@ export class RequestTokenizer { // Rough estimate: 1 token per 100 bytes of audio data totalTokens += Math.max(Math.ceil(dataSize / 100), 10); // Minimum 10 tokens per audio } catch (error) { - console.warn('Error calculating audio tokens:', error); + debugLogger.warn('Error calculating audio tokens:', error); totalTokens += 10; // Fallback minimum } } @@ -169,7 +172,7 @@ export class RequestTokenizer { // Treat other content as text, and avoid per-item rounding inflation. return await this.textTokenizer.calculateTokens(otherContents.join('')); } catch (error) { - console.warn('Error calculating other content tokens:', error); + debugLogger.warn('Error calculating other content tokens:', error); // Fallback: character-based estimation const totalChars = otherContents.join('').length; return Math.ceil(totalChars / 4); @@ -184,7 +187,7 @@ export class RequestTokenizer { const content = JSON.stringify(request.contents); return Math.ceil(content.length / 4); // Rough estimate: 1 token ≈ 4 characters } catch (error) { - console.warn('Error in fallback token calculation:', error); + debugLogger.warn('Error in fallback token calculation:', error); return 100; // Conservative fallback } } @@ -321,7 +324,7 @@ export class RequestTokenizer { otherContents.push(serialized); } } catch (error) { - console.warn('Failed to serialize unknown part type:', error); + debugLogger.warn('Failed to serialize unknown part type:', error); } } } diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 8efa98805..fd9b5c025 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -7,6 +7,9 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; import { isQwenQuotaExceededError } from './quotaErrorDetection.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('RETRY'); export interface HttpError extends Error { status?: number; @@ -121,7 +124,7 @@ export async function retryWithBackoff( if (retryAfterMs > 0) { // Respect Retry-After header if present and parsed - console.warn( + debugLogger.warn( `Attempt ${attempt} failed with status ${errorStatus ?? 'unknown'}. Retrying after explicit delay of ${retryAfterMs}ms...`, error, ); @@ -230,10 +233,10 @@ function logRetryAttempt( : `Attempt ${attempt} failed. Retrying with backoff...`; if (errorStatus === 429) { - console.warn(message, error); + debugLogger.warn(message, error); } else if (errorStatus && errorStatus >= 500 && errorStatus < 600) { - console.error(message, error); + debugLogger.error(message, error); } else { - console.warn(message, error); + debugLogger.warn(message, error); } } diff --git a/packages/core/src/utils/ripgrepUtils.ts b/packages/core/src/utils/ripgrepUtils.ts index 87998b6ab..7f6f3d9c8 100644 --- a/packages/core/src/utils/ripgrepUtils.ts +++ b/packages/core/src/utils/ripgrepUtils.ts @@ -9,6 +9,9 @@ import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; import { fileExists } from './fileUtils.js'; import { execCommand, isCommandAvailable } from './shell-utils.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('RIPGREP'); const RIPGREP_COMMAND = 'rg'; const RIPGREP_BUFFER_LIMIT = 20_000_000; // Keep buffers aligned with the original bundle. @@ -298,7 +301,7 @@ export async function runRipgrep( // Log warnings for abnormal exits (except syntax errors) if (!syntaxError && truncated) { - console.warn( + debugLogger.warn( `ripgrep exited abnormally (signal=${error.signal} code=${error.code}) with stderr:\n${stderr.trim() || '(empty)'}`, ); } diff --git a/packages/core/src/utils/safeJsonParse.ts b/packages/core/src/utils/safeJsonParse.ts index 03dc6a27f..76b796fb8 100644 --- a/packages/core/src/utils/safeJsonParse.ts +++ b/packages/core/src/utils/safeJsonParse.ts @@ -5,6 +5,9 @@ */ import { jsonrepair } from 'jsonrepair'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('JSON_PARSE'); /** * Safely parse JSON string with jsonrepair fallback for malformed JSON. @@ -34,7 +37,7 @@ export function safeJsonParse>( // jsonrepair always returns a string, so we need to parse it return JSON.parse(repairedJson) as T; } catch (repairError) { - console.error('Failed to parse JSON even with jsonrepair:', { + debugLogger.error('Failed to parse JSON even with jsonrepair:', { originalError: error, repairError, jsonString, diff --git a/packages/core/src/utils/summarizer.test.ts b/packages/core/src/utils/summarizer.test.ts index 33fa9bc02..6098e77b7 100644 --- a/packages/core/src/utils/summarizer.test.ts +++ b/packages/core/src/utils/summarizer.test.ts @@ -41,13 +41,10 @@ describe('summarizers', () => { mockGeminiClient = new GeminiClient(mockConfigInstance); (mockGeminiClient.generateContent as Mock) = vi.fn(); - - vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.clearAllMocks(); - (console.error as Mock).mockRestore(); }); describe('summarizeToolOutput', () => { @@ -107,10 +104,6 @@ describe('summarizers', () => { expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1); expect(result).toBe(longText); - expect(console.error).toHaveBeenCalledWith( - 'Failed to summarize tool output.', - error, - ); }); it('should construct the correct prompt for summarization', async () => { diff --git a/packages/core/src/utils/summarizer.ts b/packages/core/src/utils/summarizer.ts index c5290cfa2..8c2b391ea 100644 --- a/packages/core/src/utils/summarizer.ts +++ b/packages/core/src/utils/summarizer.ts @@ -13,6 +13,9 @@ import type { import type { GeminiClient } from '../core/client.js'; import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { getResponseText, partToString } from './partUtils.js'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('SUMMARIZER'); /** * A function that summarizes the result of a tool execution. @@ -90,7 +93,7 @@ export async function summarizeToolOutput( )) as unknown as GenerateContentResponse; return getResponseText(parsedResponse) || textToSummarize; } catch (error) { - console.error('Failed to summarize tool output.', error); + debugLogger.error('Failed to summarize tool output.', error); return textToSummarize; } } diff --git a/packages/core/src/utils/systemEncoding.test.ts b/packages/core/src/utils/systemEncoding.test.ts index 61558f38b..6b6ce693f 100644 --- a/packages/core/src/utils/systemEncoding.test.ts +++ b/packages/core/src/utils/systemEncoding.test.ts @@ -24,13 +24,11 @@ import { } from './systemEncoding.js'; describe('Shell Command Processor - Encoding Functions', () => { - let consoleWarnSpy: ReturnType; let mockedExecSync: ReturnType>; let mockedOsPlatform: ReturnType string>>; let mockedChardetDetect: ReturnType>; beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); mockedExecSync = vi.mocked(execSync); mockedOsPlatform = vi.mocked(os.platform); mockedChardetDetect = vi.mocked(chardetDetect); @@ -65,9 +63,6 @@ describe('Shell Command Processor - Encoding Functions', () => { it('should return null for unmapped code pages and warn', () => { expect(windowsCodePageToEncoding(99999)).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Unable to determine encoding for windows code page 99999.', - ); }); it('should handle all Windows-specific code pages', () => { @@ -109,10 +104,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = detectEncodingFromBuffer(buffer); expect(result).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Failed to detect encoding with chardet:', - expect.any(Error), - ); }); it('should return null when chardet returns null', () => { @@ -169,11 +160,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = getSystemEncoding(); expect(result).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Failed to get Windows code page using 'chcp' command", - ), - ); }); it('should return null when chcp output cannot be parsed', () => { @@ -181,11 +167,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = getSystemEncoding(); expect(result).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Failed to get Windows code page using 'chcp' command", - ), - ); }); it('should return null when code page is not a number', () => { @@ -193,11 +174,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = getSystemEncoding(); expect(result).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Failed to get Windows code page using 'chcp' command", - ), - ); }); it('should return null when code page maps to null', () => { @@ -205,10 +181,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = getSystemEncoding(); expect(result).toBe(null); - // Should warn about unknown code page from windowsCodePageToEncoding - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Unable to determine encoding for windows code page 99999.', - ); }); }); @@ -262,9 +234,6 @@ describe('Shell Command Processor - Encoding Functions', () => { const result = getSystemEncoding(); expect(result).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Failed to get locale charmap.', - ); }); it('should handle locale without encoding (no dot)', () => { diff --git a/packages/core/src/utils/systemEncoding.ts b/packages/core/src/utils/systemEncoding.ts index d76bdbabf..4bce69f4c 100644 --- a/packages/core/src/utils/systemEncoding.ts +++ b/packages/core/src/utils/systemEncoding.ts @@ -7,6 +7,9 @@ import { execSync } from 'node:child_process'; import os from 'node:os'; import { detect as chardetDetect } from 'chardet'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('ENCODING'); // Cache for system encoding to avoid repeated detection // Use undefined to indicate "not yet checked" vs null meaning "checked but failed" @@ -75,7 +78,7 @@ export function getSystemEncoding(): string | null { `Unable to parse Windows code page from 'chcp' output "${output.trim()}". `, ); } catch (error) { - console.warn( + debugLogger.warn( `Failed to get Windows code page using 'chcp' command: ${error instanceof Error ? error.message : String(error)}. ` + `Will attempt to detect encoding from command output instead.`, ); @@ -97,7 +100,7 @@ export function getSystemEncoding(): string | null { .toString() .trim(); } catch (_e) { - console.warn('Failed to get locale charmap.'); + debugLogger.warn('Failed to get locale charmap.'); return null; } } @@ -150,7 +153,7 @@ export function windowsCodePageToEncoding(cp: number): string | null { return map[cp]; } - console.warn(`Unable to determine encoding for windows code page ${cp}.`); + debugLogger.warn(`Unable to determine encoding for windows code page ${cp}.`); return null; // Return null if no mapping found } @@ -168,7 +171,7 @@ export function detectEncodingFromBuffer(buffer: Buffer): string | null { return detected.toLowerCase(); } } catch (error) { - console.warn('Failed to detect encoding with chardet:', error); + debugLogger.warn('Failed to detect encoding with chardet:', error); } return null; diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index c93dffe47..686c50ba3 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -391,32 +391,24 @@ describe('WorkspaceContext with optional directories', () => { fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(existingDir1, { recursive: true }); fs.mkdirSync(existingDir2, { recursive: true }); - - vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); - vi.restoreAllMocks(); }); - it('should skip a missing optional directory and log a warning', () => { + it('should skip a missing optional directory', () => { const workspaceContext = new WorkspaceContext(cwd, [ nonExistentDir, existingDir1, ]); const directories = workspaceContext.getDirectories(); expect(directories).toEqual([cwd, existingDir1]); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - `[WARN] Skipping unreadable directory: ${nonExistentDir} (Directory does not exist: ${nonExistentDir})`, - ); }); it('should include an existing optional directory', () => { const workspaceContext = new WorkspaceContext(cwd, [existingDir1]); const directories = workspaceContext.getDirectories(); expect(directories).toEqual([cwd, existingDir1]); - expect(console.warn).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 97db6852c..1fe1a6dfe 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -8,6 +8,9 @@ import { isNodeError } from '../utils/errors.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as process from 'node:process'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('WORKSPACE'); export type Unsubscribe = () => void; @@ -53,7 +56,7 @@ export class WorkspaceContext { listener(); } catch (e) { // Don't let one listener break others. - console.error('Error in WorkspaceContext listener:', e); + debugLogger.error('Error in WorkspaceContext listener:', e); } } } @@ -72,8 +75,8 @@ export class WorkspaceContext { this.directories.add(resolved); this.notifyDirectoriesChanged(); } catch (err) { - console.warn( - `[WARN] Skipping unreadable directory: ${directory} (${err instanceof Error ? err.message : String(err)})`, + debugLogger.warn( + `Skipping unreadable directory: ${directory} (${err instanceof Error ? err.message : String(err)})`, ); } } diff --git a/packages/core/test-setup.ts b/packages/core/test-setup.ts index 520e618ad..8d2e7f74a 100644 --- a/packages/core/test-setup.ts +++ b/packages/core/test-setup.ts @@ -11,6 +11,12 @@ if (process.env['NO_COLOR'] !== undefined) { import { setSimulate429 } from './src/utils/testUtils.js'; +// Avoid writing per-session debug log files during tests. +// Unit tests can opt-in by overriding this env var. +if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) { + process.env['QWEN_DEBUG_LOG_FILE'] = '0'; +} + // Disable 429 simulation globally for all tests setSimulate429(false);