Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as fs from 'fs';6import * as os from 'os';7import * as path from 'path';8import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';9import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';10import { IEnvService } from '../../../../platform/env/common/envService';11import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';12import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';13import { ILogService } from '../../../../platform/log/common/logService';14import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../../platform/otel/common/index';15import { ICompletedSpanData, IOTelService, ISpanEventData, SpanStatusCode } from '../../../../platform/otel/common/otelService';16import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';17import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';18import { Emitter, Event } from '../../../../util/vs/base/common/event';19import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';20import { URI } from '../../../../util/vs/base/common/uri';21import { ChatDebugFileLoggerService } from '../chatDebugFileLoggerService';2223// ── Test helpers ──2425function makeSpan(overrides: Partial<ICompletedSpanData> & { attributes?: Record<string, string | number | boolean | string[]> }): ICompletedSpanData {26return {27name: 'test-span',28spanId: 'span-1',29traceId: 'trace-1',30startTime: 1000,31endTime: 2000,32status: { code: SpanStatusCode.OK },33attributes: {},34events: [],35...overrides,36};37}3839function makeToolCallSpan(sessionId: string, toolName: string): ICompletedSpanData {40return makeSpan({41name: toolName,42attributes: {43[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,44[GenAiAttr.TOOL_NAME]: toolName,45[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,46},47});48}4950function makeChatSpan(sessionId: string, model: string, inputTokens: number, outputTokens: number): ICompletedSpanData {51return makeSpan({52name: 'chat',53attributes: {54[GenAiAttr.OPERATION_NAME]: GenAiOperationName.CHAT,55[GenAiAttr.REQUEST_MODEL]: model,56[GenAiAttr.USAGE_INPUT_TOKENS]: inputTokens,57[GenAiAttr.USAGE_OUTPUT_TOKENS]: outputTokens,58[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,59},60});61}6263class TestOTelService {64declare readonly _serviceBrand: undefined;65readonly config = {} as never;6667private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();68readonly onDidCompleteSpan = this._onDidCompleteSpan.event;6970private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();71readonly onDidEmitSpanEvent = this._onDidEmitSpanEvent.event;7273fireSpan(span: ICompletedSpanData): void {74this._onDidCompleteSpan.fire(span);75}7677fireSpanEvent(event: ISpanEventData): void {78this._onDidEmitSpanEvent.fire(event);79}8081startSpan() { return { setAttribute() { }, setAttributes() { }, setStatus() { }, recordException() { }, addEvent() { }, getSpanContext() { return undefined; }, end() { } }; }82startActiveSpan<T>(_n: string, _o: unknown, fn: (s: unknown) => Promise<T>) { return fn(this.startSpan()); }83getActiveTraceContext() { return undefined; }84storeTraceContext() { }85getStoredTraceContext() { return undefined; }86runWithTraceContext<T>(_c: unknown, fn: () => Promise<T>) { return fn(); }87recordMetric() { }88incrementCounter() { }89emitLogRecord() { }90async flush() { }91async shutdown() { }9293dispose(): void {94this._onDidCompleteSpan.dispose();95this._onDidEmitSpanEvent.dispose();96}97}9899class TestExtensionContext {100declare readonly _serviceBrand: undefined;101readonly storageUri: URI;102103constructor(tmpDir: string) {104this.storageUri = URI.file(tmpDir);105}106}107108class TestFileSystemService {109declare readonly _serviceBrand: undefined;110111async stat(uri: URI) {112const stats = await fs.promises.stat(uri.fsPath);113return { mtime: stats.mtimeMs, ctime: stats.ctimeMs, size: stats.size };114}115116async readDirectory(uri: URI) {117const entries = await fs.promises.readdir(uri.fsPath, { withFileTypes: true });118return entries.map(e => [e.name, e.isFile() ? 1 : 2] as [string, number]);119}120121async createDirectory(uri: URI) {122await fs.promises.mkdir(uri.fsPath, { recursive: true });123}124125async delete(uri: URI, options?: { recursive?: boolean }) {126const stats = await fs.promises.stat(uri.fsPath);127if (stats.isDirectory() && options?.recursive) {128await fs.promises.rm(uri.fsPath, { recursive: true, force: true });129} else {130await fs.promises.unlink(uri.fsPath);131}132}133}134135class TestLogService {136declare readonly _serviceBrand: undefined;137info() { }138warn() { }139error() { }140debug() { }141trace() { }142}143144class TestConfigurationService {145declare readonly _serviceBrand: undefined;146getConfig(key: { defaultValue: unknown }) { return key.defaultValue; }147getExperimentBasedConfig(key: { defaultValue: unknown }) {148if (key === ConfigKey.Advanced.ChatDebugFileLogging) {149return true; // Enable debug logging for tests150}151return key.defaultValue;152}153onDidChangeConfiguration = Event.None;154}155156class TestTelemetryService {157declare readonly _serviceBrand: undefined;158sendMSFTTelemetryEvent() { }159}160161class TestEnvService {162declare readonly _serviceBrand: undefined;163readonly vscodeVersion = '1.99.0-test';164getVersion() { return '0.0.0-test'; }165}166167describe('ChatDebugFileLoggerService', () => {168let disposables: DisposableStore;169let tmpDir: string;170let otelService: TestOTelService;171let service: ChatDebugFileLoggerService;172173beforeEach(async () => {174disposables = new DisposableStore();175tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'chatdebug-'));176177otelService = new TestOTelService();178179service = new ChatDebugFileLoggerService(180otelService as unknown as IOTelService,181new TestFileSystemService() as unknown as IFileSystemService,182new TestExtensionContext(tmpDir) as unknown as IVSCodeExtensionContext,183new TestLogService() as unknown as ILogService,184new TestConfigurationService() as unknown as IConfigurationService,185new NullExperimentationService() as unknown as IExperimentationService,186new TestTelemetryService() as unknown as ITelemetryService,187new TestEnvService() as unknown as IEnvService,188);189disposables.add(service);190});191192afterEach(async () => {193disposables.dispose();194otelService.dispose();195await fs.promises.rm(tmpDir, { recursive: true, force: true });196});197198async function readLogEntries(sessionId: string): Promise<Record<string, unknown>[]> {199const logPath = service.getLogPath(sessionId);200if (!logPath) { return []; }201const content = await fs.promises.readFile(logPath.fsPath, 'utf-8');202return content.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));203}204205it('writes tool call span for explicitly started session', async () => {206await service.startSession('session-1');207const span = makeToolCallSpan('session-1', 'read_file');208otelService.fireSpan(span);209210expect(service.getActiveSessionIds()).toContain('session-1');211expect(service.getLogPath('session-1')).toBeDefined();212213await service.flush('session-1');214const entries = await readLogEntries('session-1');215216expect(entries).toHaveLength(2);217expect(entries[0].type).toBe('session_start');218expect(entries[1].type).toBe('tool_call');219expect(entries[1].name).toBe('read_file');220expect(entries[1].sid).toBe('session-1');221expect(entries[1].status).toBe('ok');222});223224it('writes LLM request with token counts', async () => {225await service.startSession('session-1');226const span = makeChatSpan('session-1', 'gpt-4o', 1000, 500);227otelService.fireSpan(span);228229await service.flush('session-1');230const entries = await readLogEntries('session-1');231232expect(entries).toHaveLength(2);233expect(entries[1].type).toBe('llm_request');234expect(entries[1].name).toBe('chat:gpt-4o');235const attrs = entries[1].attrs as Record<string, unknown>;236expect(attrs.model).toBe('gpt-4o');237expect(attrs.inputTokens).toBe(1000);238expect(attrs.outputTokens).toBe(500);239});240241it('records error status from failed spans', async () => {242await service.startSession('session-1');243const span = makeSpan({244attributes: {245[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,246[GenAiAttr.TOOL_NAME]: 'run_in_terminal',247[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',248},249status: { code: SpanStatusCode.ERROR, message: 'Command failed' },250});251otelService.fireSpan(span);252253await service.flush('session-1');254const entries = await readLogEntries('session-1');255256expect(entries[1].status).toBe('error');257expect((entries[1].attrs as Record<string, unknown>).error).toBe('Command failed');258});259260it('isDebugLogUri returns true for files under debug-logs', () => {261const debugLogUri = URI.joinPath(URI.file(tmpDir), 'debug-logs', 'session-1', 'main.jsonl');262expect(service.isDebugLogUri(debugLogUri)).toBe(true);263});264265it('isDebugLogUri returns false for unrelated URIs', () => {266const otherUri = URI.file('/some/other/path/file.txt');267expect(service.isDebugLogUri(otherUri)).toBe(false);268});269270it('endSession flushes and removes session', async () => {271await service.startSession('session-1');272otelService.fireSpan(makeToolCallSpan('session-1', 'read_file'));273expect(service.getActiveSessionIds()).toContain('session-1');274275await service.endSession('session-1');276expect(service.getActiveSessionIds()).not.toContain('session-1');277278// File should have been written in directory structure279const logPath = URI.joinPath(URI.file(tmpDir), 'debug-logs', 'session-1', 'main.jsonl');280const content = await fs.promises.readFile(logPath.fsPath, 'utf-8');281expect(content.trim()).not.toBe('');282});283284it('ignores spans without a session ID', async () => {285const span = makeSpan({286attributes: {287[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,288[GenAiAttr.TOOL_NAME]: 'some_tool',289// No session ID290},291});292otelService.fireSpan(span);293294expect(service.getActiveSessionIds()).toHaveLength(0);295});296297it('truncates long attribute values', async () => {298await service.startSession('session-1');299const longArgs = 'x'.repeat(6000);300const span = makeSpan({301attributes: {302[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,303[GenAiAttr.TOOL_NAME]: 'read_file',304[GenAiAttr.TOOL_CALL_ARGUMENTS]: longArgs,305[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',306},307});308otelService.fireSpan(span);309310await service.flush('session-1');311const entries = await readLogEntries('session-1');312313const args = (entries[1].attrs as Record<string, unknown>).args as string;314expect(args.length).toBeLessThan(longArgs.length);315expect(args).toContain('[truncated]');316});317318it('routes child session spans to parent directory with cross-reference', async () => {319// First, create a parent session320otelService.fireSpan(makeToolCallSpan('parent-session', 'read_file'));321322// Fire a child session span (e.g., title generation) with parent info323const titleSpan = makeChatSpan('title-child-id', 'gpt-4o-mini', 100, 20);324const titleSpanWithParent: ICompletedSpanData = {325...titleSpan,326attributes: {327...titleSpan.attributes,328[CopilotChatAttr.PARENT_CHAT_SESSION_ID]: 'parent-session',329[CopilotChatAttr.DEBUG_LOG_LABEL]: 'title',330},331};332otelService.fireSpan(titleSpanWithParent);333334await service.flush('parent-session');335await service.flush('title-child-id');336337// Parent's main.jsonl should contain the tool call + a child_session_ref338const parentEntries = await readLogEntries('parent-session');339const refEntry = parentEntries.find(e => e.type === 'child_session_ref');340expect(refEntry).toBeDefined();341expect((refEntry!.attrs as Record<string, unknown>).childLogFile).toBe('title-title-child-id.jsonl');342expect((refEntry!.attrs as Record<string, unknown>).label).toBe('title');343344// Child's log file should be under the parent directory345const childPath = service.getLogPath('title-child-id');346expect(childPath).toBeDefined();347expect(childPath!.fsPath).toContain('parent-session');348expect(childPath!.fsPath).toContain('title-title-child-id.jsonl');349350// Child should have the session_start + LLM request entry351const childEntries = await readLogEntries('title-child-id');352expect(childEntries).toHaveLength(2);353expect(childEntries[0].type).toBe('session_start');354expect(childEntries[1].type).toBe('llm_request');355});356357it('restarts flush timer when flushIntervalMs config changes at runtime', async () => {358let configuredInterval = 4000;359const configChangeEmitter = new Emitter<{ affectsConfiguration: (key: string) => boolean }>();360361const configService = {362_serviceBrand: undefined as undefined,363getConfig: () => configuredInterval,364getExperimentBasedConfig: () => true,365onDidChangeConfiguration: configChangeEmitter.event,366};367368const svc = new ChatDebugFileLoggerService(369otelService as unknown as IOTelService,370new TestFileSystemService() as unknown as IFileSystemService,371new TestExtensionContext(tmpDir) as unknown as IVSCodeExtensionContext,372new TestLogService() as unknown as ILogService,373configService as unknown as IConfigurationService,374new NullExperimentationService() as unknown as IExperimentationService,375new TestTelemetryService() as unknown as ITelemetryService,376new TestEnvService() as unknown as IEnvService,377);378disposables.add(svc);379disposables.add(configChangeEmitter);380381// Start a session so the flush timer is running382const span = makeToolCallSpan('interval-test', 'read_file');383otelService.fireSpan(span);384expect(svc.getActiveSessionIds()).toContain('interval-test');385386// Spy on clearInterval/setInterval to verify timer restart387const clearSpy = vi.spyOn(globalThis, 'clearInterval');388const setSpy = vi.spyOn(globalThis, 'setInterval');389390// Change the configured interval and fire the config change event391configuredInterval = 8000;392configChangeEmitter.fire({393affectsConfiguration: key => key === ConfigKey.Advanced.ChatDebugFileLoggingFlushInterval.fullyQualifiedId,394});395396expect(clearSpy).toHaveBeenCalled();397expect(setSpy).toHaveBeenCalledWith(expect.any(Function), 8000);398399clearSpy.mockRestore();400setSpy.mockRestore();401});402403it('inherits session ID from parent span for child spans without session ID', async () => {404await service.startSession('session-1');405406// Parent span with session ID407const parentSpan = makeSpan({408spanId: 'parent-span-1',409attributes: {410[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,411[GenAiAttr.AGENT_NAME]: 'copilot',412[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',413},414});415otelService.fireSpan(parentSpan);416417// Child span without session ID but with parentSpanId418const childSpan = makeSpan({419spanId: 'child-span-1',420parentSpanId: 'parent-span-1',421attributes: {422[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,423[GenAiAttr.TOOL_NAME]: 'read_file',424// No CHAT_SESSION_ID — should inherit from parent425},426});427otelService.fireSpan(childSpan);428429await service.flush('session-1');430const entries = await readLogEntries('session-1');431432const toolEntry = entries.find(e => e.type === 'tool_call');433expect(toolEntry).toBeDefined();434expect(toolEntry!.name).toBe('read_file');435expect(toolEntry!.sid).toBe('session-1');436});437438it('inherits session ID from parent span for user_message span events', async () => {439await service.startSession('session-1');440441// Parent span with session ID442const parentSpan = makeSpan({443spanId: 'parent-span-2',444attributes: {445[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,446[GenAiAttr.AGENT_NAME]: 'copilot',447[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',448},449});450otelService.fireSpan(parentSpan);451452// user_message event without session ID but with parentSpanId453const spanEvent: ISpanEventData = {454spanId: 'child-event-span',455traceId: 'trace-1',456parentSpanId: 'parent-span-2',457eventName: 'user_message',458attributes: { content: 'hello world' },459timestamp: 1500,460};461otelService.fireSpanEvent(spanEvent);462463await service.flush('session-1');464const entries = await readLogEntries('session-1');465466const userMsgEntry = entries.find(e => e.type === 'user_message');467expect(userMsgEntry).toBeDefined();468expect(userMsgEntry!.sid).toBe('session-1');469expect((userMsgEntry!.attrs as Record<string, unknown>).content).toBe('hello world');470});471472it('writes models.json when model snapshot is set before session starts', async () => {473const models = [{ id: 'gpt-4o', name: 'GPT-4o', capabilities: { type: 'chat', family: 'gpt-4o' } }];474service.setModelSnapshot(models);475476await service.startSession('session-models');477await service.flush('session-models');478479const sessionDir = service.getSessionDir('session-models');480expect(sessionDir).toBeDefined();481const modelsPath = path.join(sessionDir!.fsPath, 'models.json');482const content = await fs.promises.readFile(modelsPath, 'utf-8');483const parsed = JSON.parse(content);484expect(parsed).toHaveLength(1);485expect(parsed[0].id).toBe('gpt-4o');486});487488it('writes models.json when model snapshot arrives after session starts', async () => {489await service.startSession('session-late');490await service.flush('session-late');491492// Model snapshot arrives after session already started493const models = [{ id: 'claude-sonnet', name: 'Claude Sonnet' }];494service.setModelSnapshot(models);495await service.flush('session-late');496497const sessionDir = service.getSessionDir('session-late');498expect(sessionDir).toBeDefined();499const modelsPath = path.join(sessionDir!.fsPath, 'models.json');500const content = await fs.promises.readFile(modelsPath, 'utf-8');501const parsed = JSON.parse(content);502expect(parsed).toHaveLength(1);503expect(parsed[0].id).toBe('claude-sonnet');504});505506it('does not write models.json more than once per session', async () => {507const models = [{ id: 'gpt-4o', name: 'GPT-4o' }];508service.setModelSnapshot(models);509510await service.startSession('session-dedup');511await service.flush('session-dedup');512513const sessionDir = service.getSessionDir('session-dedup');514const modelsPath = path.join(sessionDir!.fsPath, 'models.json');515516// Overwrite the file with different content to detect if it gets rewritten517await fs.promises.writeFile(modelsPath, '"sentinel"', 'utf-8');518519// Calling setModelSnapshot again should NOT overwrite for existing sessions520service.setModelSnapshot([{ id: 'new-model' }]);521522const content = await fs.promises.readFile(modelsPath, 'utf-8');523expect(content).toBe('"sentinel"');524});525526it('readEntries returns entries from flushed JSONL and unflushed buffer', async () => {527await service.startSession('session-read');528otelService.fireSpan(makeToolCallSpan('session-read', 'tool_a'));529await service.flush('session-read');530531// Fire another span without flushing — it should be in the buffer532otelService.fireSpan(makeToolCallSpan('session-read', 'tool_b'));533534const entries = await service.readEntries('session-read');535const toolEntries = entries.filter(e => e.type === 'tool_call');536expect(toolEntries.length).toBe(2);537expect(toolEntries[0].name).toBe('tool_a');538expect(toolEntries[1].name).toBe('tool_b');539});540541it('readEntries returns empty array for unknown session', async () => {542const entries = await service.readEntries('nonexistent-session');543expect(entries).toEqual([]);544});545546it('readTailEntries returns last N entries from a flushed session', async () => {547await service.startSession('session-tail');548for (let i = 0; i < 5; i++) {549otelService.fireSpan(makeToolCallSpan('session-tail', `tool_${i}`));550}551await service.flush('session-tail');552553const entries = await service.readTailEntries('session-tail', 2);554const toolEntries = entries.filter(e => e.type === 'tool_call');555// Should return the last 2 tool entries556expect(toolEntries.length).toBe(2);557expect(toolEntries[0].name).toBe('tool_3');558expect(toolEntries[1].name).toBe('tool_4');559});560561it('streamEntries calls onEntry for each parsed line', async () => {562await service.startSession('session-stream');563otelService.fireSpan(makeToolCallSpan('session-stream', 'tool_x'));564otelService.fireSpan(makeChatSpan('session-stream', 'gpt-4o', 100, 50));565await service.flush('session-stream');566567const types: string[] = [];568await service.streamEntries('session-stream', entry => {569types.push(entry.type);570});571expect(types).toContain('tool_call');572expect(types).toContain('llm_request');573});574575it('onDidEmitEntry fires for each buffered entry', async () => {576await service.startSession('session-emit');577const emitted: Array<{ sessionId: string; type: string }> = [];578const sub = service.onDidEmitEntry(({ sessionId, entry }) => {579emitted.push({ sessionId, type: entry.type });580});581582otelService.fireSpan(makeToolCallSpan('session-emit', 'tool_y'));583584sub.dispose();585586const toolEvents = emitted.filter(e => e.type === 'tool_call');587expect(toolEvents.length).toBe(1);588expect(toolEvents[0].sessionId).toBe('session-emit');589});590591// ── Subagent / child session tests ──592593it('startChildSession with parentToolSpanId sets parentSpanId on child_session_ref', async () => {594await service.startSession('parent-1');595otelService.fireSpan(makeToolCallSpan('parent-1', 'read_file'));596597service.startChildSession('child-1', 'parent-1', 'runSubagent-Explore', 'tool-span-42');598599// Fire a span for the child to trigger _ensureSession → child_session_ref600const childSpan = makeChatSpan('child-1', 'claude-haiku', 100, 20);601otelService.fireSpan(childSpan);602603await service.flush('parent-1');604await service.flush('child-1');605606const parentEntries = await readLogEntries('parent-1');607const ref = parentEntries.find(e => e.type === 'child_session_ref');608expect(ref).toBeDefined();609expect(ref!.parentSpanId).toBe('tool-span-42');610expect((ref!.attrs as Record<string, unknown>).label).toBe('runSubagent-Explore');611expect((ref!.attrs as Record<string, unknown>).childSessionId).toBe('child-1');612});613614it('startChildSession without parentToolSpanId omits parentSpanId', async () => {615await service.startSession('parent-2');616otelService.fireSpan(makeToolCallSpan('parent-2', 'read_file'));617618service.startChildSession('child-2', 'parent-2', 'title');619620const childSpan = makeChatSpan('child-2', 'gpt-4o-mini', 50, 10);621otelService.fireSpan(childSpan);622623await service.flush('parent-2');624await service.flush('child-2');625626const parentEntries = await readLogEntries('parent-2');627const ref = parentEntries.find(e => e.type === 'child_session_ref');628expect(ref).toBeDefined();629expect(ref!.parentSpanId).toBeUndefined();630});631632it('child session JSONL is written under parent directory', async () => {633await service.startSession('parent-dir');634otelService.fireSpan(makeToolCallSpan('parent-dir', 'read_file'));635636service.startChildSession('child-dir', 'parent-dir', 'runSubagent-default', 'tool-span-1');637638otelService.fireSpan(makeChatSpan('child-dir', 'claude-opus', 500, 100));639640await service.flush('parent-dir');641await service.flush('child-dir');642643const childLogPath = service.getLogPath('child-dir');644expect(childLogPath).toBeDefined();645expect(childLogPath!.fsPath).toContain('parent-dir');646expect(childLogPath!.fsPath).toContain('runSubagent-default-child-dir.jsonl');647});648649it('child_session_ref includes childLogFile for direct file read fallback', async () => {650await service.startSession('parent-file');651otelService.fireSpan(makeToolCallSpan('parent-file', 'read_file'));652653service.startChildSession('child-file', 'parent-file', 'runSubagent-Explore', 'tool-span-2');654655otelService.fireSpan(makeChatSpan('child-file', 'claude-haiku', 100, 20));656657await service.flush('parent-file');658await service.flush('child-file');659660const parentEntries = await readLogEntries('parent-file');661const ref = parentEntries.find(e => e.type === 'child_session_ref');662expect(ref).toBeDefined();663expect((ref!.attrs as Record<string, unknown>).childLogFile).toBe('runSubagent-Explore-child-file.jsonl');664expect((ref!.attrs as Record<string, unknown>).childSessionId).toBe('child-file');665});666667it('readEntries returns child session entries', async () => {668await service.startSession('parent-read');669otelService.fireSpan(makeToolCallSpan('parent-read', 'read_file'));670671service.startChildSession('child-read', 'parent-read', 'runSubagent-default');672673otelService.fireSpan(makeToolCallSpan('child-read', 'file_search'));674otelService.fireSpan(makeChatSpan('child-read', 'claude-haiku', 200, 50));675676await service.flush('parent-read');677await service.flush('child-read');678679const childEntries = await service.readEntries('child-read');680const types = childEntries.map(e => e.type);681expect(types).toContain('session_start');682expect(types).toContain('tool_call');683expect(types).toContain('llm_request');684expect(childEntries.length).toBe(3);685});686687it('multiple child sessions under same parent each get their own file', async () => {688await service.startSession('parent-multi');689otelService.fireSpan(makeToolCallSpan('parent-multi', 'read_file'));690691service.startChildSession('child-a', 'parent-multi', 'runSubagent-Explore', 'tool-a');692service.startChildSession('child-b', 'parent-multi', 'runSubagent-default', 'tool-b');693694otelService.fireSpan(makeChatSpan('child-a', 'claude-haiku', 100, 20));695otelService.fireSpan(makeChatSpan('child-b', 'claude-haiku', 150, 30));696697await service.flush('parent-multi');698await service.flush('child-a');699await service.flush('child-b');700701// Parent should have two child_session_ref entries702const parentEntries = await readLogEntries('parent-multi');703const refs = parentEntries.filter(e => e.type === 'child_session_ref');704expect(refs).toHaveLength(2);705706const labels = refs.map(r => (r.attrs as Record<string, unknown>).label);707expect(labels).toContain('runSubagent-Explore');708expect(labels).toContain('runSubagent-default');709710// Each child has its own log path711const pathA = service.getLogPath('child-a');712const pathB = service.getLogPath('child-b');713expect(pathA).toBeDefined();714expect(pathB).toBeDefined();715expect(pathA!.fsPath).not.toBe(pathB!.fsPath);716});717718it('routes hook spans to child session when registerSpanSession maps parentSpanId', async () => {719// Set up parent session720await service.startSession('parent-hook');721otelService.fireSpan(makeToolCallSpan('parent-hook', 'read_file'));722723// Register child session and its invoke_agent span ID724service.startChildSession('child-hook', 'parent-hook', 'runSubagent-default');725service.registerSpanSession('invoke-agent-span-123', 'child-hook');726727// Fire a hook span with CHAT_SESSION_ID=parent but parentSpanId=child's invoke_agent728const hookSpan = makeSpan({729name: 'PreToolUse',730spanId: 'hook-span-1',731parentSpanId: 'invoke-agent-span-123',732attributes: {733[GenAiAttr.OPERATION_NAME]: 'execute_hook',734[CopilotChatAttr.CHAT_SESSION_ID]: 'parent-hook', // Parent's session ID735},736});737otelService.fireSpan(hookSpan);738739await service.flush('parent-hook');740await service.flush('child-hook');741742// Hook should be written to child session, not parent743const parentEntries = await readLogEntries('parent-hook');744const parentHooks = parentEntries.filter(e => e.type === 'hook');745expect(parentHooks).toHaveLength(0);746747const childEntries = await service.readEntries('child-hook');748const childHooks = childEntries.filter(e => e.type === 'hook');749expect(childHooks).toHaveLength(1);750expect(childHooks[0].name).toBe('PreToolUse');751});752753describe('listSessionIds', () => {754it('returns empty when no sessions exist', async () => {755const ids = await service.listSessionIds();756expect(ids).toHaveLength(0);757});758759it('lists session directories on disk', async () => {760await service.startSession('session-a');761otelService.fireSpan(makeToolCallSpan('session-a', 'read_file'));762await service.flush('session-a');763764await service.startSession('session-b');765otelService.fireSpan(makeToolCallSpan('session-b', 'edit_file'));766await service.flush('session-b');767768const ids = await service.listSessionIds();769expect(ids).toContain('session-a');770expect(ids).toContain('session-b');771});772773it('returns sessions sorted by most recently modified first', async () => {774await service.startSession('older-session');775otelService.fireSpan(makeToolCallSpan('older-session', 'read_file'));776await service.flush('older-session');777778// Small delay so mtime differs779await new Promise(resolve => setTimeout(resolve, 50));780781await service.startSession('newer-session');782otelService.fireSpan(makeToolCallSpan('newer-session', 'edit_file'));783await service.flush('newer-session');784785const ids = await service.listSessionIds();786expect(ids.indexOf('newer-session')).toBeLessThan(ids.indexOf('older-session'));787});788789it('does not include non-directory entries', async () => {790// Create a session directory791await service.startSession('real-session');792otelService.fireSpan(makeToolCallSpan('real-session', 'read_file'));793await service.flush('real-session');794795// Create a stray file in the debug-logs directory796const debugLogsDir = service.debugLogsDir!;797await fs.promises.writeFile(path.join(debugLogsDir.fsPath, 'stray-file.jsonl'), '{}');798799const ids = await service.listSessionIds();800expect(ids).toContain('real-session');801expect(ids).not.toContain('stray-file.jsonl');802});803804it('handles stat failures gracefully', async () => {805await service.startSession('good-session');806otelService.fireSpan(makeToolCallSpan('good-session', 'read_file'));807await service.flush('good-session');808809// Create an empty directory that can be listed but stat should still work810const debugLogsDir = service.debugLogsDir!;811await fs.promises.mkdir(path.join(debugLogsDir.fsPath, 'empty-dir'));812813const ids = await service.listSessionIds();814expect(ids).toContain('good-session');815expect(ids).toContain('empty-dir');816});817});818});819820821