Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as fs from 'fs';
7
import * as os from 'os';
8
import * as path from 'path';
9
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
11
import { IEnvService } from '../../../../platform/env/common/envService';
12
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
13
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
14
import { ILogService } from '../../../../platform/log/common/logService';
15
import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../../platform/otel/common/index';
16
import { ICompletedSpanData, IOTelService, ISpanEventData, SpanStatusCode } from '../../../../platform/otel/common/otelService';
17
import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
18
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
19
import { Emitter, Event } from '../../../../util/vs/base/common/event';
20
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
21
import { URI } from '../../../../util/vs/base/common/uri';
22
import { ChatDebugFileLoggerService } from '../chatDebugFileLoggerService';
23
24
// ── Test helpers ──
25
26
function makeSpan(overrides: Partial<ICompletedSpanData> & { attributes?: Record<string, string | number | boolean | string[]> }): ICompletedSpanData {
27
return {
28
name: 'test-span',
29
spanId: 'span-1',
30
traceId: 'trace-1',
31
startTime: 1000,
32
endTime: 2000,
33
status: { code: SpanStatusCode.OK },
34
attributes: {},
35
events: [],
36
...overrides,
37
};
38
}
39
40
function makeToolCallSpan(sessionId: string, toolName: string): ICompletedSpanData {
41
return makeSpan({
42
name: toolName,
43
attributes: {
44
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
45
[GenAiAttr.TOOL_NAME]: toolName,
46
[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,
47
},
48
});
49
}
50
51
function makeChatSpan(sessionId: string, model: string, inputTokens: number, outputTokens: number): ICompletedSpanData {
52
return makeSpan({
53
name: 'chat',
54
attributes: {
55
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.CHAT,
56
[GenAiAttr.REQUEST_MODEL]: model,
57
[GenAiAttr.USAGE_INPUT_TOKENS]: inputTokens,
58
[GenAiAttr.USAGE_OUTPUT_TOKENS]: outputTokens,
59
[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,
60
},
61
});
62
}
63
64
class TestOTelService {
65
declare readonly _serviceBrand: undefined;
66
readonly config = {} as never;
67
68
private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();
69
readonly onDidCompleteSpan = this._onDidCompleteSpan.event;
70
71
private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();
72
readonly onDidEmitSpanEvent = this._onDidEmitSpanEvent.event;
73
74
fireSpan(span: ICompletedSpanData): void {
75
this._onDidCompleteSpan.fire(span);
76
}
77
78
fireSpanEvent(event: ISpanEventData): void {
79
this._onDidEmitSpanEvent.fire(event);
80
}
81
82
startSpan() { return { setAttribute() { }, setAttributes() { }, setStatus() { }, recordException() { }, addEvent() { }, getSpanContext() { return undefined; }, end() { } }; }
83
startActiveSpan<T>(_n: string, _o: unknown, fn: (s: unknown) => Promise<T>) { return fn(this.startSpan()); }
84
getActiveTraceContext() { return undefined; }
85
storeTraceContext() { }
86
getStoredTraceContext() { return undefined; }
87
runWithTraceContext<T>(_c: unknown, fn: () => Promise<T>) { return fn(); }
88
recordMetric() { }
89
incrementCounter() { }
90
emitLogRecord() { }
91
async flush() { }
92
async shutdown() { }
93
94
dispose(): void {
95
this._onDidCompleteSpan.dispose();
96
this._onDidEmitSpanEvent.dispose();
97
}
98
}
99
100
class TestExtensionContext {
101
declare readonly _serviceBrand: undefined;
102
readonly storageUri: URI;
103
104
constructor(tmpDir: string) {
105
this.storageUri = URI.file(tmpDir);
106
}
107
}
108
109
class TestFileSystemService {
110
declare readonly _serviceBrand: undefined;
111
112
async stat(uri: URI) {
113
const stats = await fs.promises.stat(uri.fsPath);
114
return { mtime: stats.mtimeMs, ctime: stats.ctimeMs, size: stats.size };
115
}
116
117
async readDirectory(uri: URI) {
118
const entries = await fs.promises.readdir(uri.fsPath, { withFileTypes: true });
119
return entries.map(e => [e.name, e.isFile() ? 1 : 2] as [string, number]);
120
}
121
122
async createDirectory(uri: URI) {
123
await fs.promises.mkdir(uri.fsPath, { recursive: true });
124
}
125
126
async delete(uri: URI, options?: { recursive?: boolean }) {
127
const stats = await fs.promises.stat(uri.fsPath);
128
if (stats.isDirectory() && options?.recursive) {
129
await fs.promises.rm(uri.fsPath, { recursive: true, force: true });
130
} else {
131
await fs.promises.unlink(uri.fsPath);
132
}
133
}
134
}
135
136
class TestLogService {
137
declare readonly _serviceBrand: undefined;
138
info() { }
139
warn() { }
140
error() { }
141
debug() { }
142
trace() { }
143
}
144
145
class TestConfigurationService {
146
declare readonly _serviceBrand: undefined;
147
getConfig(key: { defaultValue: unknown }) { return key.defaultValue; }
148
getExperimentBasedConfig(key: { defaultValue: unknown }) {
149
if (key === ConfigKey.Advanced.ChatDebugFileLogging) {
150
return true; // Enable debug logging for tests
151
}
152
return key.defaultValue;
153
}
154
onDidChangeConfiguration = Event.None;
155
}
156
157
class TestTelemetryService {
158
declare readonly _serviceBrand: undefined;
159
sendMSFTTelemetryEvent() { }
160
}
161
162
class TestEnvService {
163
declare readonly _serviceBrand: undefined;
164
readonly vscodeVersion = '1.99.0-test';
165
getVersion() { return '0.0.0-test'; }
166
}
167
168
describe('ChatDebugFileLoggerService', () => {
169
let disposables: DisposableStore;
170
let tmpDir: string;
171
let otelService: TestOTelService;
172
let service: ChatDebugFileLoggerService;
173
174
beforeEach(async () => {
175
disposables = new DisposableStore();
176
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'chatdebug-'));
177
178
otelService = new TestOTelService();
179
180
service = new ChatDebugFileLoggerService(
181
otelService as unknown as IOTelService,
182
new TestFileSystemService() as unknown as IFileSystemService,
183
new TestExtensionContext(tmpDir) as unknown as IVSCodeExtensionContext,
184
new TestLogService() as unknown as ILogService,
185
new TestConfigurationService() as unknown as IConfigurationService,
186
new NullExperimentationService() as unknown as IExperimentationService,
187
new TestTelemetryService() as unknown as ITelemetryService,
188
new TestEnvService() as unknown as IEnvService,
189
);
190
disposables.add(service);
191
});
192
193
afterEach(async () => {
194
disposables.dispose();
195
otelService.dispose();
196
await fs.promises.rm(tmpDir, { recursive: true, force: true });
197
});
198
199
async function readLogEntries(sessionId: string): Promise<Record<string, unknown>[]> {
200
const logPath = service.getLogPath(sessionId);
201
if (!logPath) { return []; }
202
const content = await fs.promises.readFile(logPath.fsPath, 'utf-8');
203
return content.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
204
}
205
206
it('writes tool call span for explicitly started session', async () => {
207
await service.startSession('session-1');
208
const span = makeToolCallSpan('session-1', 'read_file');
209
otelService.fireSpan(span);
210
211
expect(service.getActiveSessionIds()).toContain('session-1');
212
expect(service.getLogPath('session-1')).toBeDefined();
213
214
await service.flush('session-1');
215
const entries = await readLogEntries('session-1');
216
217
expect(entries).toHaveLength(2);
218
expect(entries[0].type).toBe('session_start');
219
expect(entries[1].type).toBe('tool_call');
220
expect(entries[1].name).toBe('read_file');
221
expect(entries[1].sid).toBe('session-1');
222
expect(entries[1].status).toBe('ok');
223
});
224
225
it('writes LLM request with token counts', async () => {
226
await service.startSession('session-1');
227
const span = makeChatSpan('session-1', 'gpt-4o', 1000, 500);
228
otelService.fireSpan(span);
229
230
await service.flush('session-1');
231
const entries = await readLogEntries('session-1');
232
233
expect(entries).toHaveLength(2);
234
expect(entries[1].type).toBe('llm_request');
235
expect(entries[1].name).toBe('chat:gpt-4o');
236
const attrs = entries[1].attrs as Record<string, unknown>;
237
expect(attrs.model).toBe('gpt-4o');
238
expect(attrs.inputTokens).toBe(1000);
239
expect(attrs.outputTokens).toBe(500);
240
});
241
242
it('records error status from failed spans', async () => {
243
await service.startSession('session-1');
244
const span = makeSpan({
245
attributes: {
246
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
247
[GenAiAttr.TOOL_NAME]: 'run_in_terminal',
248
[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',
249
},
250
status: { code: SpanStatusCode.ERROR, message: 'Command failed' },
251
});
252
otelService.fireSpan(span);
253
254
await service.flush('session-1');
255
const entries = await readLogEntries('session-1');
256
257
expect(entries[1].status).toBe('error');
258
expect((entries[1].attrs as Record<string, unknown>).error).toBe('Command failed');
259
});
260
261
it('isDebugLogUri returns true for files under debug-logs', () => {
262
const debugLogUri = URI.joinPath(URI.file(tmpDir), 'debug-logs', 'session-1', 'main.jsonl');
263
expect(service.isDebugLogUri(debugLogUri)).toBe(true);
264
});
265
266
it('isDebugLogUri returns false for unrelated URIs', () => {
267
const otherUri = URI.file('/some/other/path/file.txt');
268
expect(service.isDebugLogUri(otherUri)).toBe(false);
269
});
270
271
it('endSession flushes and removes session', async () => {
272
await service.startSession('session-1');
273
otelService.fireSpan(makeToolCallSpan('session-1', 'read_file'));
274
expect(service.getActiveSessionIds()).toContain('session-1');
275
276
await service.endSession('session-1');
277
expect(service.getActiveSessionIds()).not.toContain('session-1');
278
279
// File should have been written in directory structure
280
const logPath = URI.joinPath(URI.file(tmpDir), 'debug-logs', 'session-1', 'main.jsonl');
281
const content = await fs.promises.readFile(logPath.fsPath, 'utf-8');
282
expect(content.trim()).not.toBe('');
283
});
284
285
it('ignores spans without a session ID', async () => {
286
const span = makeSpan({
287
attributes: {
288
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
289
[GenAiAttr.TOOL_NAME]: 'some_tool',
290
// No session ID
291
},
292
});
293
otelService.fireSpan(span);
294
295
expect(service.getActiveSessionIds()).toHaveLength(0);
296
});
297
298
it('truncates long attribute values', async () => {
299
await service.startSession('session-1');
300
const longArgs = 'x'.repeat(6000);
301
const span = makeSpan({
302
attributes: {
303
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
304
[GenAiAttr.TOOL_NAME]: 'read_file',
305
[GenAiAttr.TOOL_CALL_ARGUMENTS]: longArgs,
306
[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',
307
},
308
});
309
otelService.fireSpan(span);
310
311
await service.flush('session-1');
312
const entries = await readLogEntries('session-1');
313
314
const args = (entries[1].attrs as Record<string, unknown>).args as string;
315
expect(args.length).toBeLessThan(longArgs.length);
316
expect(args).toContain('[truncated]');
317
});
318
319
it('routes child session spans to parent directory with cross-reference', async () => {
320
// First, create a parent session
321
otelService.fireSpan(makeToolCallSpan('parent-session', 'read_file'));
322
323
// Fire a child session span (e.g., title generation) with parent info
324
const titleSpan = makeChatSpan('title-child-id', 'gpt-4o-mini', 100, 20);
325
const titleSpanWithParent: ICompletedSpanData = {
326
...titleSpan,
327
attributes: {
328
...titleSpan.attributes,
329
[CopilotChatAttr.PARENT_CHAT_SESSION_ID]: 'parent-session',
330
[CopilotChatAttr.DEBUG_LOG_LABEL]: 'title',
331
},
332
};
333
otelService.fireSpan(titleSpanWithParent);
334
335
await service.flush('parent-session');
336
await service.flush('title-child-id');
337
338
// Parent's main.jsonl should contain the tool call + a child_session_ref
339
const parentEntries = await readLogEntries('parent-session');
340
const refEntry = parentEntries.find(e => e.type === 'child_session_ref');
341
expect(refEntry).toBeDefined();
342
expect((refEntry!.attrs as Record<string, unknown>).childLogFile).toBe('title-title-child-id.jsonl');
343
expect((refEntry!.attrs as Record<string, unknown>).label).toBe('title');
344
345
// Child's log file should be under the parent directory
346
const childPath = service.getLogPath('title-child-id');
347
expect(childPath).toBeDefined();
348
expect(childPath!.fsPath).toContain('parent-session');
349
expect(childPath!.fsPath).toContain('title-title-child-id.jsonl');
350
351
// Child should have the session_start + LLM request entry
352
const childEntries = await readLogEntries('title-child-id');
353
expect(childEntries).toHaveLength(2);
354
expect(childEntries[0].type).toBe('session_start');
355
expect(childEntries[1].type).toBe('llm_request');
356
});
357
358
it('restarts flush timer when flushIntervalMs config changes at runtime', async () => {
359
let configuredInterval = 4000;
360
const configChangeEmitter = new Emitter<{ affectsConfiguration: (key: string) => boolean }>();
361
362
const configService = {
363
_serviceBrand: undefined as undefined,
364
getConfig: () => configuredInterval,
365
getExperimentBasedConfig: () => true,
366
onDidChangeConfiguration: configChangeEmitter.event,
367
};
368
369
const svc = new ChatDebugFileLoggerService(
370
otelService as unknown as IOTelService,
371
new TestFileSystemService() as unknown as IFileSystemService,
372
new TestExtensionContext(tmpDir) as unknown as IVSCodeExtensionContext,
373
new TestLogService() as unknown as ILogService,
374
configService as unknown as IConfigurationService,
375
new NullExperimentationService() as unknown as IExperimentationService,
376
new TestTelemetryService() as unknown as ITelemetryService,
377
new TestEnvService() as unknown as IEnvService,
378
);
379
disposables.add(svc);
380
disposables.add(configChangeEmitter);
381
382
// Start a session so the flush timer is running
383
const span = makeToolCallSpan('interval-test', 'read_file');
384
otelService.fireSpan(span);
385
expect(svc.getActiveSessionIds()).toContain('interval-test');
386
387
// Spy on clearInterval/setInterval to verify timer restart
388
const clearSpy = vi.spyOn(globalThis, 'clearInterval');
389
const setSpy = vi.spyOn(globalThis, 'setInterval');
390
391
// Change the configured interval and fire the config change event
392
configuredInterval = 8000;
393
configChangeEmitter.fire({
394
affectsConfiguration: key => key === ConfigKey.Advanced.ChatDebugFileLoggingFlushInterval.fullyQualifiedId,
395
});
396
397
expect(clearSpy).toHaveBeenCalled();
398
expect(setSpy).toHaveBeenCalledWith(expect.any(Function), 8000);
399
400
clearSpy.mockRestore();
401
setSpy.mockRestore();
402
});
403
404
it('inherits session ID from parent span for child spans without session ID', async () => {
405
await service.startSession('session-1');
406
407
// Parent span with session ID
408
const parentSpan = makeSpan({
409
spanId: 'parent-span-1',
410
attributes: {
411
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,
412
[GenAiAttr.AGENT_NAME]: 'copilot',
413
[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',
414
},
415
});
416
otelService.fireSpan(parentSpan);
417
418
// Child span without session ID but with parentSpanId
419
const childSpan = makeSpan({
420
spanId: 'child-span-1',
421
parentSpanId: 'parent-span-1',
422
attributes: {
423
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
424
[GenAiAttr.TOOL_NAME]: 'read_file',
425
// No CHAT_SESSION_ID — should inherit from parent
426
},
427
});
428
otelService.fireSpan(childSpan);
429
430
await service.flush('session-1');
431
const entries = await readLogEntries('session-1');
432
433
const toolEntry = entries.find(e => e.type === 'tool_call');
434
expect(toolEntry).toBeDefined();
435
expect(toolEntry!.name).toBe('read_file');
436
expect(toolEntry!.sid).toBe('session-1');
437
});
438
439
it('inherits session ID from parent span for user_message span events', async () => {
440
await service.startSession('session-1');
441
442
// Parent span with session ID
443
const parentSpan = makeSpan({
444
spanId: 'parent-span-2',
445
attributes: {
446
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,
447
[GenAiAttr.AGENT_NAME]: 'copilot',
448
[CopilotChatAttr.CHAT_SESSION_ID]: 'session-1',
449
},
450
});
451
otelService.fireSpan(parentSpan);
452
453
// user_message event without session ID but with parentSpanId
454
const spanEvent: ISpanEventData = {
455
spanId: 'child-event-span',
456
traceId: 'trace-1',
457
parentSpanId: 'parent-span-2',
458
eventName: 'user_message',
459
attributes: { content: 'hello world' },
460
timestamp: 1500,
461
};
462
otelService.fireSpanEvent(spanEvent);
463
464
await service.flush('session-1');
465
const entries = await readLogEntries('session-1');
466
467
const userMsgEntry = entries.find(e => e.type === 'user_message');
468
expect(userMsgEntry).toBeDefined();
469
expect(userMsgEntry!.sid).toBe('session-1');
470
expect((userMsgEntry!.attrs as Record<string, unknown>).content).toBe('hello world');
471
});
472
473
it('writes models.json when model snapshot is set before session starts', async () => {
474
const models = [{ id: 'gpt-4o', name: 'GPT-4o', capabilities: { type: 'chat', family: 'gpt-4o' } }];
475
service.setModelSnapshot(models);
476
477
await service.startSession('session-models');
478
await service.flush('session-models');
479
480
const sessionDir = service.getSessionDir('session-models');
481
expect(sessionDir).toBeDefined();
482
const modelsPath = path.join(sessionDir!.fsPath, 'models.json');
483
const content = await fs.promises.readFile(modelsPath, 'utf-8');
484
const parsed = JSON.parse(content);
485
expect(parsed).toHaveLength(1);
486
expect(parsed[0].id).toBe('gpt-4o');
487
});
488
489
it('writes models.json when model snapshot arrives after session starts', async () => {
490
await service.startSession('session-late');
491
await service.flush('session-late');
492
493
// Model snapshot arrives after session already started
494
const models = [{ id: 'claude-sonnet', name: 'Claude Sonnet' }];
495
service.setModelSnapshot(models);
496
await service.flush('session-late');
497
498
const sessionDir = service.getSessionDir('session-late');
499
expect(sessionDir).toBeDefined();
500
const modelsPath = path.join(sessionDir!.fsPath, 'models.json');
501
const content = await fs.promises.readFile(modelsPath, 'utf-8');
502
const parsed = JSON.parse(content);
503
expect(parsed).toHaveLength(1);
504
expect(parsed[0].id).toBe('claude-sonnet');
505
});
506
507
it('does not write models.json more than once per session', async () => {
508
const models = [{ id: 'gpt-4o', name: 'GPT-4o' }];
509
service.setModelSnapshot(models);
510
511
await service.startSession('session-dedup');
512
await service.flush('session-dedup');
513
514
const sessionDir = service.getSessionDir('session-dedup');
515
const modelsPath = path.join(sessionDir!.fsPath, 'models.json');
516
517
// Overwrite the file with different content to detect if it gets rewritten
518
await fs.promises.writeFile(modelsPath, '"sentinel"', 'utf-8');
519
520
// Calling setModelSnapshot again should NOT overwrite for existing sessions
521
service.setModelSnapshot([{ id: 'new-model' }]);
522
523
const content = await fs.promises.readFile(modelsPath, 'utf-8');
524
expect(content).toBe('"sentinel"');
525
});
526
527
it('readEntries returns entries from flushed JSONL and unflushed buffer', async () => {
528
await service.startSession('session-read');
529
otelService.fireSpan(makeToolCallSpan('session-read', 'tool_a'));
530
await service.flush('session-read');
531
532
// Fire another span without flushing — it should be in the buffer
533
otelService.fireSpan(makeToolCallSpan('session-read', 'tool_b'));
534
535
const entries = await service.readEntries('session-read');
536
const toolEntries = entries.filter(e => e.type === 'tool_call');
537
expect(toolEntries.length).toBe(2);
538
expect(toolEntries[0].name).toBe('tool_a');
539
expect(toolEntries[1].name).toBe('tool_b');
540
});
541
542
it('readEntries returns empty array for unknown session', async () => {
543
const entries = await service.readEntries('nonexistent-session');
544
expect(entries).toEqual([]);
545
});
546
547
it('readTailEntries returns last N entries from a flushed session', async () => {
548
await service.startSession('session-tail');
549
for (let i = 0; i < 5; i++) {
550
otelService.fireSpan(makeToolCallSpan('session-tail', `tool_${i}`));
551
}
552
await service.flush('session-tail');
553
554
const entries = await service.readTailEntries('session-tail', 2);
555
const toolEntries = entries.filter(e => e.type === 'tool_call');
556
// Should return the last 2 tool entries
557
expect(toolEntries.length).toBe(2);
558
expect(toolEntries[0].name).toBe('tool_3');
559
expect(toolEntries[1].name).toBe('tool_4');
560
});
561
562
it('streamEntries calls onEntry for each parsed line', async () => {
563
await service.startSession('session-stream');
564
otelService.fireSpan(makeToolCallSpan('session-stream', 'tool_x'));
565
otelService.fireSpan(makeChatSpan('session-stream', 'gpt-4o', 100, 50));
566
await service.flush('session-stream');
567
568
const types: string[] = [];
569
await service.streamEntries('session-stream', entry => {
570
types.push(entry.type);
571
});
572
expect(types).toContain('tool_call');
573
expect(types).toContain('llm_request');
574
});
575
576
it('onDidEmitEntry fires for each buffered entry', async () => {
577
await service.startSession('session-emit');
578
const emitted: Array<{ sessionId: string; type: string }> = [];
579
const sub = service.onDidEmitEntry(({ sessionId, entry }) => {
580
emitted.push({ sessionId, type: entry.type });
581
});
582
583
otelService.fireSpan(makeToolCallSpan('session-emit', 'tool_y'));
584
585
sub.dispose();
586
587
const toolEvents = emitted.filter(e => e.type === 'tool_call');
588
expect(toolEvents.length).toBe(1);
589
expect(toolEvents[0].sessionId).toBe('session-emit');
590
});
591
592
// ── Subagent / child session tests ──
593
594
it('startChildSession with parentToolSpanId sets parentSpanId on child_session_ref', async () => {
595
await service.startSession('parent-1');
596
otelService.fireSpan(makeToolCallSpan('parent-1', 'read_file'));
597
598
service.startChildSession('child-1', 'parent-1', 'runSubagent-Explore', 'tool-span-42');
599
600
// Fire a span for the child to trigger _ensureSession → child_session_ref
601
const childSpan = makeChatSpan('child-1', 'claude-haiku', 100, 20);
602
otelService.fireSpan(childSpan);
603
604
await service.flush('parent-1');
605
await service.flush('child-1');
606
607
const parentEntries = await readLogEntries('parent-1');
608
const ref = parentEntries.find(e => e.type === 'child_session_ref');
609
expect(ref).toBeDefined();
610
expect(ref!.parentSpanId).toBe('tool-span-42');
611
expect((ref!.attrs as Record<string, unknown>).label).toBe('runSubagent-Explore');
612
expect((ref!.attrs as Record<string, unknown>).childSessionId).toBe('child-1');
613
});
614
615
it('startChildSession without parentToolSpanId omits parentSpanId', async () => {
616
await service.startSession('parent-2');
617
otelService.fireSpan(makeToolCallSpan('parent-2', 'read_file'));
618
619
service.startChildSession('child-2', 'parent-2', 'title');
620
621
const childSpan = makeChatSpan('child-2', 'gpt-4o-mini', 50, 10);
622
otelService.fireSpan(childSpan);
623
624
await service.flush('parent-2');
625
await service.flush('child-2');
626
627
const parentEntries = await readLogEntries('parent-2');
628
const ref = parentEntries.find(e => e.type === 'child_session_ref');
629
expect(ref).toBeDefined();
630
expect(ref!.parentSpanId).toBeUndefined();
631
});
632
633
it('child session JSONL is written under parent directory', async () => {
634
await service.startSession('parent-dir');
635
otelService.fireSpan(makeToolCallSpan('parent-dir', 'read_file'));
636
637
service.startChildSession('child-dir', 'parent-dir', 'runSubagent-default', 'tool-span-1');
638
639
otelService.fireSpan(makeChatSpan('child-dir', 'claude-opus', 500, 100));
640
641
await service.flush('parent-dir');
642
await service.flush('child-dir');
643
644
const childLogPath = service.getLogPath('child-dir');
645
expect(childLogPath).toBeDefined();
646
expect(childLogPath!.fsPath).toContain('parent-dir');
647
expect(childLogPath!.fsPath).toContain('runSubagent-default-child-dir.jsonl');
648
});
649
650
it('child_session_ref includes childLogFile for direct file read fallback', async () => {
651
await service.startSession('parent-file');
652
otelService.fireSpan(makeToolCallSpan('parent-file', 'read_file'));
653
654
service.startChildSession('child-file', 'parent-file', 'runSubagent-Explore', 'tool-span-2');
655
656
otelService.fireSpan(makeChatSpan('child-file', 'claude-haiku', 100, 20));
657
658
await service.flush('parent-file');
659
await service.flush('child-file');
660
661
const parentEntries = await readLogEntries('parent-file');
662
const ref = parentEntries.find(e => e.type === 'child_session_ref');
663
expect(ref).toBeDefined();
664
expect((ref!.attrs as Record<string, unknown>).childLogFile).toBe('runSubagent-Explore-child-file.jsonl');
665
expect((ref!.attrs as Record<string, unknown>).childSessionId).toBe('child-file');
666
});
667
668
it('readEntries returns child session entries', async () => {
669
await service.startSession('parent-read');
670
otelService.fireSpan(makeToolCallSpan('parent-read', 'read_file'));
671
672
service.startChildSession('child-read', 'parent-read', 'runSubagent-default');
673
674
otelService.fireSpan(makeToolCallSpan('child-read', 'file_search'));
675
otelService.fireSpan(makeChatSpan('child-read', 'claude-haiku', 200, 50));
676
677
await service.flush('parent-read');
678
await service.flush('child-read');
679
680
const childEntries = await service.readEntries('child-read');
681
const types = childEntries.map(e => e.type);
682
expect(types).toContain('session_start');
683
expect(types).toContain('tool_call');
684
expect(types).toContain('llm_request');
685
expect(childEntries.length).toBe(3);
686
});
687
688
it('multiple child sessions under same parent each get their own file', async () => {
689
await service.startSession('parent-multi');
690
otelService.fireSpan(makeToolCallSpan('parent-multi', 'read_file'));
691
692
service.startChildSession('child-a', 'parent-multi', 'runSubagent-Explore', 'tool-a');
693
service.startChildSession('child-b', 'parent-multi', 'runSubagent-default', 'tool-b');
694
695
otelService.fireSpan(makeChatSpan('child-a', 'claude-haiku', 100, 20));
696
otelService.fireSpan(makeChatSpan('child-b', 'claude-haiku', 150, 30));
697
698
await service.flush('parent-multi');
699
await service.flush('child-a');
700
await service.flush('child-b');
701
702
// Parent should have two child_session_ref entries
703
const parentEntries = await readLogEntries('parent-multi');
704
const refs = parentEntries.filter(e => e.type === 'child_session_ref');
705
expect(refs).toHaveLength(2);
706
707
const labels = refs.map(r => (r.attrs as Record<string, unknown>).label);
708
expect(labels).toContain('runSubagent-Explore');
709
expect(labels).toContain('runSubagent-default');
710
711
// Each child has its own log path
712
const pathA = service.getLogPath('child-a');
713
const pathB = service.getLogPath('child-b');
714
expect(pathA).toBeDefined();
715
expect(pathB).toBeDefined();
716
expect(pathA!.fsPath).not.toBe(pathB!.fsPath);
717
});
718
719
it('routes hook spans to child session when registerSpanSession maps parentSpanId', async () => {
720
// Set up parent session
721
await service.startSession('parent-hook');
722
otelService.fireSpan(makeToolCallSpan('parent-hook', 'read_file'));
723
724
// Register child session and its invoke_agent span ID
725
service.startChildSession('child-hook', 'parent-hook', 'runSubagent-default');
726
service.registerSpanSession('invoke-agent-span-123', 'child-hook');
727
728
// Fire a hook span with CHAT_SESSION_ID=parent but parentSpanId=child's invoke_agent
729
const hookSpan = makeSpan({
730
name: 'PreToolUse',
731
spanId: 'hook-span-1',
732
parentSpanId: 'invoke-agent-span-123',
733
attributes: {
734
[GenAiAttr.OPERATION_NAME]: 'execute_hook',
735
[CopilotChatAttr.CHAT_SESSION_ID]: 'parent-hook', // Parent's session ID
736
},
737
});
738
otelService.fireSpan(hookSpan);
739
740
await service.flush('parent-hook');
741
await service.flush('child-hook');
742
743
// Hook should be written to child session, not parent
744
const parentEntries = await readLogEntries('parent-hook');
745
const parentHooks = parentEntries.filter(e => e.type === 'hook');
746
expect(parentHooks).toHaveLength(0);
747
748
const childEntries = await service.readEntries('child-hook');
749
const childHooks = childEntries.filter(e => e.type === 'hook');
750
expect(childHooks).toHaveLength(1);
751
expect(childHooks[0].name).toBe('PreToolUse');
752
});
753
754
describe('listSessionIds', () => {
755
it('returns empty when no sessions exist', async () => {
756
const ids = await service.listSessionIds();
757
expect(ids).toHaveLength(0);
758
});
759
760
it('lists session directories on disk', async () => {
761
await service.startSession('session-a');
762
otelService.fireSpan(makeToolCallSpan('session-a', 'read_file'));
763
await service.flush('session-a');
764
765
await service.startSession('session-b');
766
otelService.fireSpan(makeToolCallSpan('session-b', 'edit_file'));
767
await service.flush('session-b');
768
769
const ids = await service.listSessionIds();
770
expect(ids).toContain('session-a');
771
expect(ids).toContain('session-b');
772
});
773
774
it('returns sessions sorted by most recently modified first', async () => {
775
await service.startSession('older-session');
776
otelService.fireSpan(makeToolCallSpan('older-session', 'read_file'));
777
await service.flush('older-session');
778
779
// Small delay so mtime differs
780
await new Promise(resolve => setTimeout(resolve, 50));
781
782
await service.startSession('newer-session');
783
otelService.fireSpan(makeToolCallSpan('newer-session', 'edit_file'));
784
await service.flush('newer-session');
785
786
const ids = await service.listSessionIds();
787
expect(ids.indexOf('newer-session')).toBeLessThan(ids.indexOf('older-session'));
788
});
789
790
it('does not include non-directory entries', async () => {
791
// Create a session directory
792
await service.startSession('real-session');
793
otelService.fireSpan(makeToolCallSpan('real-session', 'read_file'));
794
await service.flush('real-session');
795
796
// Create a stray file in the debug-logs directory
797
const debugLogsDir = service.debugLogsDir!;
798
await fs.promises.writeFile(path.join(debugLogsDir.fsPath, 'stray-file.jsonl'), '{}');
799
800
const ids = await service.listSessionIds();
801
expect(ids).toContain('real-session');
802
expect(ids).not.toContain('stray-file.jsonl');
803
});
804
805
it('handles stat failures gracefully', async () => {
806
await service.startSession('good-session');
807
otelService.fireSpan(makeToolCallSpan('good-session', 'read_file'));
808
await service.flush('good-session');
809
810
// Create an empty directory that can be listed but stat should still work
811
const debugLogsDir = service.debugLogsDir!;
812
await fs.promises.mkdir(path.join(debugLogsDir.fsPath, 'empty-dir'));
813
814
const ids = await service.listSessionIds();
815
expect(ids).toContain('good-session');
816
expect(ids).toContain('empty-dir');
817
});
818
});
819
});
820
821