diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 6abf4128e74..2c36f3b9d21 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -212,16 +212,17 @@ this setting. Commands for obtaining information and performing system settings. -| Command | Description | Usage Examples | -| ----------- | ----------------------------------------------- | -------------------------------- | -| `/help` | Display help information for available commands | `/help` or `/?` | -| `/about` | Display version information | `/about` | -| `/stats` | Display detailed statistics for current session | `/stats` | -| `/settings` | Open settings editor | `/settings` | -| `/auth` | Change authentication method | `/auth` | -| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | -| `/copy` | Copy last output content to clipboard | `/copy` | -| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | +| Command | Description | Usage Examples | +| --------------- | ----------------------------------------------- | -------------------------------- | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/status` | Display version information | `/status` or `/about` | +| `/status paths` | Display current session file and log paths | `/status paths` | +| `/stats` | Display detailed statistics for current session | `/stats` | +| `/settings` | Open settings editor | `/settings` | +| `/auth` | Change authentication method | `/auth` | +| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | +| `/copy` | Copy last output content to clipboard | `/copy` | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | ### 1.9 Common Shortcuts diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5aaa832fc85..50ea0850315 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -14,6 +14,7 @@ import { type MockInstance, } from 'vitest'; import { + createNonInteractivePromptId, main, setupUnhandledRejectionHandler, validateDnsResolutionOrder, @@ -309,6 +310,12 @@ describe('gemini.tsx main function', () => { ); }); + it('creates non-interactive prompt ids that preserve session correlation', () => { + expect(createNonInteractivePromptId('test-session-id')).toBe( + 'test-session-id########0', + ); + }); + const runSandboxRelaunch = async ( argv: string[], sessionId = '123e4567-e89b-12d3-a456-426614174000', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index e2bb65171d1..94061489e9b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -891,7 +891,7 @@ export async function main() { settings, ); - const prompt_id = Math.random().toString(16).slice(2); + const prompt_id = createNonInteractivePromptId(config.getSessionId()); if (inputFormat === InputFormat.STREAM_JSON) { const trimmedInput = (input ?? '').trim(); @@ -948,6 +948,10 @@ export async function main() { } } +export function createNonInteractivePromptId(sessionId: string): string { + return `${sessionId}########0`; +} + function setWindowTitle(title: string, settings: LoadedSettings) { if (!settings.merged.ui?.hideWindowTitle) { const windowTitle = computeWindowTitle(title); diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 29db173bc27..4e1a135b48c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -84,6 +84,8 @@ export default { 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', 'for help on Qwen Code': 'for help on Qwen Code', 'show version info': 'show version info', + 'show paths for current session files and logs': + 'show paths for current session files and logs', 'submit a bug report': 'submit a bug report', Status: 'Status', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index b6ddc78cbec..deecf402831 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -78,6 +78,7 @@ export default { 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', 'for help on Qwen Code': '獲取 Qwen Code 幫助', 'show version info': '顯示版本信息', + 'show paths for current session files and logs': '顯示目前會話檔案和日誌路徑', 'submit a bug report': '提交錯誤報告', Status: '狀態', 'Qwen Code': 'Qwen Code', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index bb804745c3a..6caaba91954 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -81,6 +81,7 @@ export default { 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', 'for help on Qwen Code': '获取 Qwen Code 帮助', 'show version info': '显示版本信息', + 'show paths for current session files and logs': '显示当前会话文件和日志路径', 'submit a bug report': '提交错误报告', Status: '状态', diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 26b4cd2f536..2a5bf58870c 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -10,8 +10,10 @@ import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import * as systemInfoUtils from '../../utils/systemInfo.js'; +import * as sessionPathsUtils from '../../utils/sessionPaths.js'; vi.mock('../../utils/systemInfo.js'); +vi.mock('../../utils/sessionPaths.js'); describe('aboutCommand', () => { let mockContext: CommandContext; @@ -55,6 +57,12 @@ describe('aboutCommand', () => { memoryUsage: '100 MB', baseUrl: undefined, }); + vi.mocked(sessionPathsUtils.collectSessionPathInfo).mockResolvedValue({ + sections: [], + }); + vi.mocked(sessionPathsUtils.formatSessionPathInfo).mockReturnValue( + 'Session files:\n Session ID: test-session-id', + ); }); afterEach(() => { @@ -281,6 +289,31 @@ describe('aboutCommand', () => { ); }); + it('paths subcommand should return current session file paths', async () => { + const pathsSubCommand = aboutCommand.subCommands?.find( + (sc) => sc.name === 'paths', + ); + if (!pathsSubCommand?.action) { + throw new Error('The paths subcommand must have an action.'); + } + + const result = (await pathsSubCommand.action(mockContext, '')) as { + type: string; + messageType: string; + content: string; + }; + + expect(sessionPathsUtils.collectSessionPathInfo).toHaveBeenCalledWith( + mockContext, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session files:\n Session ID: test-session-id', + }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }); + describe('non-interactive mode', () => { it('should return text summary without calling addItem', async () => { if (!aboutCommand.action) { @@ -341,6 +374,29 @@ describe('aboutCommand', () => { expect(result.content).toContain('vscode'); }); + it('paths subcommand should return text without calling addItem', async () => { + const pathsSubCommand = aboutCommand.subCommands?.find( + (sc) => sc.name === 'paths', + ); + if (!pathsSubCommand?.action) { + throw new Error('The paths subcommand must have an action.'); + } + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + } as unknown as Partial); + nonInteractiveContext.ui.addItem = vi.fn(); + + const result = await pathsSubCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session files:\n Session ID: test-session-id', + }); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + it('should include LSP status when available', async () => { if (!aboutCommand.action) throw new Error('No action'); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index f44f81b89eb..1e0e98a8d6c 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -9,6 +9,10 @@ import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAbout } from '../types.js'; import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; import { t } from '../../i18n/index.js'; +import { + collectSessionPathInfo, + formatSessionPathInfo, +} from '../../utils/sessionPaths.js'; export const aboutCommand: SlashCommand = { name: 'status', @@ -51,4 +55,23 @@ export const aboutCommand: SlashCommand = { context.ui.addItem(aboutItem, Date.now()); return; }, + subCommands: [ + { + name: 'paths', + get description() { + return t('show paths for current session files and logs'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context) => { + const info = await collectSessionPathInfo(context); + const content = formatSessionPathInfo(info); + return { + type: 'message' as const, + messageType: 'info' as const, + content, + }; + }, + }, + ], }; diff --git a/packages/cli/src/utils/sessionPaths.test.ts b/packages/cli/src/utils/sessionPaths.test.ts new file mode 100644 index 00000000000..56cc82d6739 --- /dev/null +++ b/packages/cli/src/utils/sessionPaths.test.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; +import { + collectSessionPathInfo, + formatSessionPathInfo, +} from './sessionPaths.js'; +import type { CommandContext } from '../ui/commands/types.js'; + +describe('sessionPaths', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-paths-')); + vi.stubEnv('QWEN_RUNTIME_DIR', path.join(tmpDir, 'runtime')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('collects current session paths and latest matching OpenAI log', async () => { + const sessionId = '2a25a035-da35-4722-850e-b8aa074bd244'; + const planFilePath = path.join(tmpDir, 'plans', `${sessionId}.md`); + await fs.mkdir(path.dirname(planFilePath), { recursive: true }); + await fs.writeFile(planFilePath, '# Plan\n', 'utf-8'); + const openAILogDir = path.join(tmpDir, 'openai-logs'); + await fs.mkdir(openAILogDir, { recursive: true }); + await fs.writeFile( + path.join(openAILogDir, 'openai-2026-01-01T00-00-00-000Z-old.json'), + JSON.stringify({ context: { sessionId } }), + 'utf-8', + ); + const latestOpenAILog = path.join( + openAILogDir, + 'openai-2026-01-02T00-00-00-000Z-new.json', + ); + await fs.writeFile( + latestOpenAILog, + JSON.stringify({ context: { sessionId } }), + 'utf-8', + ); + + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi + .fn() + .mockReturnValue(`/tmp/chats/${sessionId}.jsonl`), + getDebugMode: vi.fn().mockReturnValue(true), + getPlanFilePath: vi.fn().mockReturnValue(planFilePath), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain(`Session ID: ${sessionId}`); + expect(text).toContain(`Transcript: /tmp/chats/${sessionId}.jsonl`); + expect(text).toContain(`Debug log: ${Storage.getDebugLogPath(sessionId)}`); + expect(text).toContain(`Plan file: ${planFilePath}`); + expect(text).toContain(`Directory: ${openAILogDir}`); + expect(text).toContain(`Latest for session: ${latestOpenAILog}`); + }); + + it('matches OpenAI logs by promptId when sessionId is absent', async () => { + const sessionId = '2a25a035-da35-4722-850e-b8aa074bd244'; + const openAILogDir = path.join(tmpDir, 'openai-logs'); + await fs.mkdir(openAILogDir, { recursive: true }); + const latestOpenAILog = path.join( + openAILogDir, + 'openai-2026-01-02T00-00-00-000Z-new.json', + ); + await fs.writeFile( + latestOpenAILog, + JSON.stringify({ context: { promptId: sessionId } }), + 'utf-8', + ); + + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi.fn().mockReturnValue(''), + getDebugMode: vi.fn().mockReturnValue(false), + getPlanFilePath: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain(`Latest for session: ${latestOpenAILog}`); + }); + + it('formats session path sections with indentation and separators', () => { + const text = formatSessionPathInfo({ + sections: [ + { + title: 'Session files', + entries: [ + { label: 'Session ID', value: 'session-id' }, + { label: 'Transcript', value: '/tmp/session.jsonl' }, + ], + }, + { + title: 'OpenAI logs', + entries: [{ label: 'Directory', value: '/tmp/openai-logs' }], + }, + ], + }); + + expect(text).toBe( + [ + 'Session files:', + ' Session ID: session-id', + ' Transcript: /tmp/session.jsonl', + '', + 'OpenAI logs:', + ' Directory: /tmp/openai-logs', + ].join('\n'), + ); + }); + + it('limits OpenAI log JSON scans to recent files', async () => { + const sessionId = '2a25a035-da35-4722-850e-b8aa074bd244'; + const openAILogDir = path.join(tmpDir, 'openai-logs'); + await fs.mkdir(openAILogDir, { recursive: true }); + for (let i = 0; i <= 100; i++) { + await fs.writeFile( + path.join( + openAILogDir, + `openai-2026-01-01T00-00-00-000Z-${String(i).padStart(3, '0')}.json`, + ), + JSON.stringify({ + context: i === 0 ? { sessionId } : { sessionId: 'other-session' }, + }), + 'utf-8', + ); + } + const readFileSpy = vi.spyOn(fs, 'readFile'); + + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi.fn().mockReturnValue(''), + getDebugMode: vi.fn().mockReturnValue(false), + getPlanFilePath: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain('Latest for session: none yet'); + expect(readFileSpy).toHaveBeenCalledTimes(100); + }); + + it('keeps session output when the OpenAI log directory is unreadable', async () => { + const sessionId = '2a25a035-da35-4722-850e-b8aa074bd244'; + const openAILogDir = path.join(tmpDir, 'openai-logs'); + vi.spyOn(fs, 'readdir').mockRejectedValue( + Object.assign(new Error('denied'), { code: 'EACCES' }), + ); + + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi.fn().mockReturnValue(`/tmp/${sessionId}.jsonl`), + getDebugMode: vi.fn().mockReturnValue(false), + getPlanFilePath: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain(`Session ID: ${sessionId}`); + expect(text).toContain(`Transcript: /tmp/${sessionId}.jsonl`); + expect(text).toContain(`Directory: ${openAILogDir}`); + expect(text).toContain('Latest for session: none yet'); + }); + + it('keeps session output when the OpenAI log directory is missing', async () => { + const sessionId = '2a25a035-da35-4722-850e-b8aa074bd244'; + const openAILogDir = path.join(tmpDir, 'missing-openai-logs'); + const readdirSpy = vi + .spyOn(fs, 'readdir') + .mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }), + ); + + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi.fn().mockReturnValue(`/tmp/${sessionId}.jsonl`), + getDebugMode: vi.fn().mockReturnValue(false), + getPlanFilePath: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(readdirSpy).toHaveBeenCalledOnce(); + expect(text).toContain(`Session ID: ${sessionId}`); + expect(text).toContain(`Directory: ${openAILogDir}`); + expect(text).toContain('Latest for session: none yet'); + }); + + it('handles an unknown session id without derived path lookups', async () => { + const openAILogDir = path.join(tmpDir, 'openai-logs'); + const readdirSpy = vi.spyOn(fs, 'readdir'); + const accessSpy = vi.spyOn(fs, 'access'); + + const context = createMockCommandContext({ + session: { + stats: { + sessionId: undefined, + }, + }, + services: { + config: { + getSessionId: vi.fn().mockReturnValue(''), + getTranscriptPath: vi.fn().mockReturnValue('/tmp/unknown.jsonl'), + getDebugMode: vi.fn().mockReturnValue(true), + getPlanFilePath: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + openAILoggingDir: openAILogDir, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain('Session ID: unknown'); + expect(text).toContain('Transcript: /tmp/unknown.jsonl'); + expect(text).toContain(`Directory: ${openAILogDir}`); + expect(text).toContain('Latest for session: none yet'); + expect(text).not.toContain('Debug log:'); + expect(text).not.toContain('Plan file:'); + expect(readdirSpy).not.toHaveBeenCalled(); + expect(accessSpy).not.toHaveBeenCalled(); + }); + + it('hides disabled or absent log sections and missing plan files', async () => { + const sessionId = 'session-id'; + const context = createMockCommandContext({ + services: { + config: { + getSessionId: vi.fn().mockReturnValue(sessionId), + getTranscriptPath: vi.fn().mockReturnValue(`/tmp/${sessionId}.jsonl`), + getDebugMode: vi.fn().mockReturnValue(false), + getPlanFilePath: vi + .fn() + .mockReturnValue(path.join(tmpDir, 'missing-plan.md')), + getWorkingDir: vi.fn().mockReturnValue(tmpDir), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: false, + }), + }, + }, + } as unknown as CommandContext); + + const text = formatSessionPathInfo(await collectSessionPathInfo(context)); + + expect(text).toContain(`Session ID: ${sessionId}`); + expect(text).toContain(`Transcript: /tmp/${sessionId}.jsonl`); + expect(text).not.toContain('Debug log:'); + expect(text).not.toContain('Plan file:'); + expect(text).not.toContain('OpenAI logs:'); + }); +}); diff --git a/packages/cli/src/utils/sessionPaths.ts b/packages/cli/src/utils/sessionPaths.ts new file mode 100644 index 00000000000..f61e0b7caa6 --- /dev/null +++ b/packages/cli/src/utils/sessionPaths.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + createDebugLogger, + resolveOpenAILogDir, + Storage, +} from '@qwen-code/qwen-code-core'; +import type { CommandContext } from '../ui/commands/types.js'; + +const debugLogger = createDebugLogger('SESSION_PATHS'); +const OPENAI_LOG_SCAN_LIMIT = 100; + +export interface SessionPathEntry { + label: string; + value: string; +} + +export interface SessionPathSection { + title: string; + entries: SessionPathEntry[]; +} + +export interface SessionPathInfo { + sections: SessionPathSection[]; +} + +export async function collectSessionPathInfo( + context: CommandContext, +): Promise { + const config = context.services.config; + const sessionId = + config?.getSessionId() || context.session.stats.sessionId || 'unknown'; + const contentGeneratorConfig = config?.getContentGeneratorConfig(); + const workingDir = config?.getWorkingDir() || process.cwd(); + const openAILogDir = resolveOpenAILogDir( + contentGeneratorConfig?.openAILoggingDir, + workingDir, + ); + const openAILoggingEnabled = + contentGeneratorConfig?.enableOpenAILogging === true; + const latestOpenAILog = + openAILoggingEnabled && sessionId !== 'unknown' + ? await findLatestOpenAILogForSession(openAILogDir, sessionId) + : undefined; + const transcriptPath = config?.getTranscriptPath() || ''; + const debugLogPath = + config?.getDebugMode() && sessionId !== 'unknown' + ? Storage.getDebugLogPath(sessionId) + : ''; + const planFilePath = + config?.getPlanFilePath() || + (sessionId === 'unknown' ? '' : Storage.getPlanFilePath(sessionId)); + const planFileExists = planFilePath ? await pathExists(planFilePath) : false; + + const sections: SessionPathSection[] = [ + { + title: 'Session files', + entries: [ + { label: 'Session ID', value: sessionId }, + ...(transcriptPath + ? [{ label: 'Transcript', value: transcriptPath }] + : []), + ...(debugLogPath ? [{ label: 'Debug log', value: debugLogPath }] : []), + ...(planFileExists + ? [{ label: 'Plan file', value: planFilePath }] + : []), + ], + }, + ]; + + if (openAILoggingEnabled) { + sections.push({ + title: 'OpenAI logs', + entries: [ + { label: 'Directory', value: openAILogDir }, + { label: 'Latest for session', value: latestOpenAILog ?? 'none yet' }, + ], + }); + } + + return { + sections, + }; +} + +export function formatSessionPathInfo(info: SessionPathInfo): string { + const lines: string[] = []; + for (const [index, section] of info.sections.entries()) { + if (index > 0) { + lines.push(''); + } + lines.push(`${section.title}:`); + for (const entry of section.entries) { + lines.push(` ${entry.label}: ${entry.value}`); + } + } + return lines.join('\n'); +} + +async function findLatestOpenAILogForSession( + logDir: string, + sessionId: string, +): Promise { + const files = await listLogFiles(logDir, (name) => + /^openai-.*\.json$/.test(name), + ); + for (const file of files) { + try { + const raw = await fs.readFile(file, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (hasContextSessionId(parsed, sessionId)) { + return file; + } + } catch (error) { + if ( + error instanceof SyntaxError || + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + continue; + } + debugLogger.warn('Error reading OpenAI log file', file, error); + } + } + return undefined; +} + +async function listLogFiles( + dir: string, + predicate: (name: string) => boolean, +): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + if (!entry.isFile() || !predicate(entry.name)) { + continue; + } + insertRecentFile(files, path.join(dir, entry.name)); + } + return files; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn('Unable to list OpenAI log directory', dir, error); + } + return []; + } +} + +function insertRecentFile(files: string[], file: string): void { + const insertAt = files.findIndex((existing) => file > existing); + if (insertAt === -1) { + if (files.length < OPENAI_LOG_SCAN_LIMIT) { + files.push(file); + } + return; + } + + files.splice(insertAt, 0, file); + if (files.length > OPENAI_LOG_SCAN_LIMIT) { + files.pop(); + } +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function hasContextSessionId(value: unknown, sessionId: string): boolean { + if (!value || typeof value !== 'object') { + return false; + } + const context = (value as { context?: unknown }).context; + if (!context || typeof context !== 'object') { + return false; + } + const ctx = context as { sessionId?: unknown; promptId?: unknown }; + return ctx.sessionId === sessionId || ctx.promptId === sessionId; +} diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 18dcc9f0d5e..cbcef7e9c11 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -16,6 +16,7 @@ * and how to interpret the results. */ +import { randomUUID } from 'node:crypto'; import { reportError } from '../../utils/errorReporting.js'; import { subagentNameContext } from '../../utils/subagentNameContext.js'; import type { Config } from '../../config/config.js'; @@ -271,7 +272,7 @@ export class AgentCore { hooks?: AgentHooks, runtimeView?: RuntimeContentGeneratorView, ) { - const randomPart = Math.random().toString(36).slice(2, 8); + const randomPart = randomUUID().replace(/-/g, '').slice(0, 8); this.subagentId = `${name}-${randomPart}`; this.name = name; this.runtimeContext = runtimeContext; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ebd1d4d897..286f4499f7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -291,7 +291,11 @@ export * from './utils/memoryDiscovery.js'; export * from './utils/modelId.js'; export { ConditionalRulesRegistry } from './utils/rulesDiscovery.js'; export type { RuleFile } from './utils/rulesDiscovery.js'; -export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; +export { + OpenAILogger, + openaiLogger, + resolveOpenAILogDir, +} from './utils/openaiLogger.js'; export * from './utils/partUtils.js'; export * from './utils/sessionStorageUtils.js'; export * from './utils/pathReader.js'; diff --git a/packages/core/src/utils/openaiLogger.test.ts b/packages/core/src/utils/openaiLogger.test.ts index 283ac44b7cb..19e49f64535 100644 --- a/packages/core/src/utils/openaiLogger.test.ts +++ b/packages/core/src/utils/openaiLogger.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as path from 'node:path'; import * as os from 'os'; import { promises as fs } from 'node:fs'; -import { OpenAILogger } from './openaiLogger.js'; +import { OpenAILogger, resolveOpenAILogDir } from './openaiLogger.js'; describe('OpenAILogger', () => { let originalCwd: string; @@ -90,6 +90,20 @@ describe('OpenAILogger', () => { const logger = new OpenAILogger(customDir); expect(logger).toBeInstanceOf(OpenAILogger); }); + + it('should resolve OpenAI log directories without constructing a logger', () => { + const customCwd = path.join(testTempDir, 'project-root'); + + expect(resolveOpenAILogDir(undefined, customCwd)).toBe( + path.join(customCwd, 'logs', 'openai'), + ); + expect(resolveOpenAILogDir('relative-logs', customCwd)).toBe( + path.resolve(customCwd, 'relative-logs'), + ); + expect(resolveOpenAILogDir('~/custom-logs', customCwd)).toBe( + path.join(os.homedir(), 'custom-logs'), + ); + }); }); describe('initialize', () => { @@ -239,6 +253,51 @@ describe('OpenAILogger', () => { expect(path.basename(logPath)).toMatch( /openai-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-[a-f0-9]{8}\.json/, ); + + const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')); + expect(logContent.context).toEqual({ + promptId: 'e097d32b-82d6-422a-afa6-f6184565a8ab########0', + sessionId: 'e097d32b-82d6-422a-afa6-f6184565a8ab', + }); + }); + + it('should derive session id from bare UUID prompt ids', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + const sessionId = 'e097d32b-82d6-422a-afa6-f6184565a8ab'; + + const logPath = await logger.logInteraction( + { model: 'claude-opus-4-7' }, + { id: 'test-id', choices: [] }, + undefined, + sessionId, + ); + + const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')); + expect(logContent.context).toEqual({ + promptId: sessionId, + sessionId, + }); + }); + + it('should derive session id from subagent prompt ids with extra separators', async () => { + const logger = new OpenAILogger(testTempDir); + await logger.initialize(); + const sessionId = 'e097d32b-82d6-422a-afa6-f6184565a8ab'; + const promptId = `${sessionId}#Explore#nested#7`; + + const logPath = await logger.logInteraction( + { model: 'claude-opus-4-7' }, + { id: 'test-id', choices: [] }, + undefined, + promptId, + ); + + const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')); + expect(logContent.context).toEqual({ + promptId, + sessionId, + }); }); it('should write correct log data structure', async () => { @@ -258,6 +317,7 @@ describe('OpenAILogger', () => { expect(logContent).toHaveProperty('request', request); expect(logContent).toHaveProperty('response', response); expect(logContent).toHaveProperty('error', null); + expect(logContent).toHaveProperty('context', null); expect(logContent).toHaveProperty('system'); expect(logContent.system).toHaveProperty('hostname'); expect(logContent.system).toHaveProperty('platform'); diff --git a/packages/core/src/utils/openaiLogger.ts b/packages/core/src/utils/openaiLogger.ts index 0807292427d..21c17d2c8be 100644 --- a/packages/core/src/utils/openaiLogger.ts +++ b/packages/core/src/utils/openaiLogger.ts @@ -12,6 +12,32 @@ import { createDebugLogger } from './debugLogger.js'; import { isInternalPromptId } from './internalPromptIds.js'; const debugLogger = createDebugLogger('OPENAI_LOGGER'); +const MAIN_SESSION_PROMPT_ID_DELIMITER = '########'; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface OpenAILogContext { + promptId?: string; + sessionId?: string; +} + +export function resolveOpenAILogDir( + customLogDir?: string, + cwd?: string, +): string { + const baseCwd = cwd || process.cwd(); + if (!customLogDir) { + return path.join(baseCwd, 'logs', 'openai'); + } + + let resolvedPath = customLogDir; + if (customLogDir === '~' || customLogDir.startsWith('~/')) { + resolvedPath = path.join(os.homedir(), customLogDir.slice(1)); + } else if (!path.isAbsolute(customLogDir)) { + resolvedPath = path.resolve(baseCwd, customLogDir); + } + return path.normalize(resolvedPath); +} function sanitizeDiagnosticSuffix( suffix: string | undefined, @@ -45,6 +71,46 @@ function promptIdSuffixForFilename( return sanitizeDiagnosticSuffix(extractSubagentSuffix(promptId)); } +function sessionIdFromPromptId( + promptId: string | undefined, +): string | undefined { + if (!promptId) return undefined; + + const mainSessionDelimiterIndex = promptId.indexOf( + MAIN_SESSION_PROMPT_ID_DELIMITER, + ); + if (mainSessionDelimiterIndex > 0) { + return promptId.slice(0, mainSessionDelimiterIndex); + } + + if (UUID_PATTERN.test(promptId)) { + return promptId; + } + + const parts = promptId.split('#'); + if (parts.length >= 3 && parts[0]) { + return parts[0]; + } + + return undefined; +} + +function contextForPromptId( + promptId: string | undefined, +): OpenAILogContext | null { + const trimmedPromptId = promptId?.trim(); + const sessionId = sessionIdFromPromptId(trimmedPromptId); + + if (!trimmedPromptId && !sessionId) { + return null; + } + + return { + ...(trimmedPromptId ? { promptId: trimmedPromptId } : {}), + ...(sessionId ? { sessionId } : {}), + }; +} + /** * Logger specifically for OpenAI API requests and responses */ @@ -60,21 +126,7 @@ export class OpenAILogger { * pass the project working directory from Config.getWorkingDir(). */ constructor(customLogDir?: string, cwd?: string) { - const baseCwd = cwd || process.cwd(); - if (customLogDir) { - // Resolve relative paths to absolute paths - // Handle ~ expansion - let resolvedPath = customLogDir; - if (customLogDir === '~' || customLogDir.startsWith('~/')) { - resolvedPath = path.join(os.homedir(), customLogDir.slice(1)); - } else if (!path.isAbsolute(customLogDir)) { - // If it's a relative path, resolve it relative to provided working directory - resolvedPath = path.resolve(baseCwd, customLogDir); - } - this.logDir = path.normalize(resolvedPath); - } else { - this.logDir = path.join(baseCwd, 'logs', 'openai'); - } + this.logDir = resolveOpenAILogDir(customLogDir, cwd); } /** @@ -129,6 +181,7 @@ export class OpenAILogger { stack: error.stack, } : null, + context: contextForPromptId(promptId), system: { hostname: os.hostname(), platform: os.platform(),