Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts
13399 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 {
8
IHistoricalTurn,
9
ISessionTranscriptService,
10
ToolRequest,
11
TranscriptEntry,
12
} from '../../../platform/chat/common/sessionTranscriptService';
13
import { IEnvService } from '../../../platform/env/common/envService';
14
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
15
import { IFileSystemService, createDirectoryIfNotExists } from '../../../platform/filesystem/common/fileSystemService';
16
import { ILogService } from '../../../platform/log/common/logService';
17
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
18
import { URI } from '../../../util/vs/base/common/uri';
19
import { generateUuid } from '../../../util/vs/base/common/uuid';
20
21
const TRANSCRIPT_VERSION = 1;
22
const TRANSCRIPT_PRODUCER = 'copilot-agent';
23
const DEFAULT_MAX_RETAINED = 20;
24
25
/**
26
* Strip the internal `__vscode-<number>` suffix that is appended to tool-call
27
* IDs for uniqueness inside VS Code. The transcript should contain only the
28
* original model-generated ID.
29
*/
30
function stripInternalToolCallId(id: string): string {
31
return id.split('__vscode-')[0];
32
}
33
34
interface IActiveSession {
35
readonly uri: URI;
36
lastEntryId: string | null;
37
/** Buffered JSONL lines waiting to be flushed to disk. */
38
readonly buffer: string[];
39
/** Chain of flush operations to serialize writes. */
40
flushPromise: Promise<void>;
41
/** Running count of lines in the transcript (flushed + buffered). */
42
lineCount: number;
43
}
44
45
export class SessionTranscriptService implements ISessionTranscriptService {
46
declare readonly _serviceBrand: undefined;
47
48
private readonly _activeSessions = new Map<string, IActiveSession>();
49
private _transcriptsDirUri: URI | undefined;
50
51
constructor(
52
@IFileSystemService private readonly _fileSystemService: IFileSystemService,
53
@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext,
54
@IEnvService private readonly _envService: IEnvService,
55
@ILogService private readonly _logService: ILogService,
56
) { }
57
58
private _getTranscriptsDir(): URI | undefined {
59
if (this._transcriptsDirUri) {
60
return this._transcriptsDirUri;
61
}
62
const storageUri = this._extensionContext.storageUri;
63
if (!storageUri) {
64
return undefined;
65
}
66
this._transcriptsDirUri = URI.joinPath(storageUri, 'transcripts');
67
return this._transcriptsDirUri;
68
}
69
70
async startSession(sessionId: string, context?: { cwd?: string }, history?: readonly IHistoricalTurn[]): Promise<void> {
71
if (this._activeSessions.has(sessionId)) {
72
return;
73
}
74
75
const dir = this._getTranscriptsDir();
76
if (!dir) {
77
this._logService.warn('[SessionTranscript] No workspace storage available, transcript will not be written');
78
return;
79
}
80
81
try {
82
await createDirectoryIfNotExists(this._fileSystemService, dir);
83
} catch (err) {
84
this._logService.error('[SessionTranscript] Failed to create transcripts directory', err);
85
return;
86
}
87
88
const fileUri = URI.joinPath(dir, `${sessionId}.jsonl`);
89
const session: IActiveSession = {
90
uri: fileUri,
91
lastEntryId: null,
92
buffer: [],
93
flushPromise: Promise.resolve(),
94
lineCount: 0,
95
};
96
this._activeSessions.set(sessionId, session);
97
98
// If the file already exists on disk, skip history replay and just pick up from here
99
let fileAlreadyExists = false;
100
try {
101
await this._fileSystemService.stat(fileUri);
102
fileAlreadyExists = true;
103
} catch {
104
// File doesn't exist yet
105
}
106
107
if (fileAlreadyExists) {
108
// Session file exists — we're resuming; count existing lines so getLineCount stays accurate
109
try {
110
const content = await fs.promises.readFile(fileUri.fsPath, 'utf-8');
111
session.lineCount = content.split('\n').filter(l => l.length > 0).length;
112
} catch {
113
}
114
return;
115
}
116
117
const startTime = (history && history.length > 0)
118
? new Date(history[0].timestamp).toISOString()
119
: new Date().toISOString();
120
121
this._bufferEntry(sessionId, {
122
type: 'session.start',
123
data: {
124
sessionId,
125
version: TRANSCRIPT_VERSION,
126
producer: TRANSCRIPT_PRODUCER,
127
copilotVersion: this._envService.getVersion(),
128
vscodeVersion: this._envService.vscodeVersion,
129
startTime,
130
context,
131
},
132
});
133
134
// Replay historical turns if provided
135
if (history) {
136
this._replayHistory(sessionId, history);
137
}
138
139
// Fire-and-forget cleanup of old transcripts
140
this.cleanupOldTranscripts().catch(() => { });
141
}
142
143
logUserMessage(sessionId: string, content: string, attachments?: readonly unknown[]): void {
144
this._bufferEntry(sessionId, {
145
type: 'user.message',
146
data: {
147
content,
148
attachments: attachments ?? [],
149
},
150
});
151
}
152
153
logAssistantTurnStart(sessionId: string, turnId: string): void {
154
this._bufferEntry(sessionId, {
155
type: 'assistant.turn_start',
156
data: { turnId },
157
});
158
}
159
160
logAssistantMessage(sessionId: string, content: string, toolRequests: readonly ToolRequest[], reasoningText?: string): void {
161
this._bufferEntry(sessionId, {
162
type: 'assistant.message',
163
data: {
164
messageId: generateUuid(),
165
content,
166
toolRequests: toolRequests.map(tr => ({ ...tr, toolCallId: stripInternalToolCallId(tr.toolCallId) })),
167
...(reasoningText !== undefined ? { reasoningText } : {}),
168
},
169
});
170
}
171
172
logToolExecutionStart(sessionId: string, toolCallId: string, toolName: string, args: unknown): void {
173
this._bufferEntry(sessionId, {
174
type: 'tool.execution_start',
175
data: {
176
toolCallId: stripInternalToolCallId(toolCallId),
177
toolName,
178
arguments: args,
179
},
180
});
181
}
182
183
logToolExecutionComplete(sessionId: string, toolCallId: string, success: boolean, resultContent?: string): void {
184
this._bufferEntry(sessionId, {
185
type: 'tool.execution_complete',
186
data: {
187
toolCallId: stripInternalToolCallId(toolCallId),
188
success,
189
...(resultContent !== undefined ? { result: { content: resultContent } } : {}),
190
},
191
});
192
}
193
194
logAssistantTurnEnd(sessionId: string, turnId: string): void {
195
this._bufferEntry(sessionId, {
196
type: 'assistant.turn_end',
197
data: { turnId },
198
});
199
}
200
201
async flush(sessionId: string): Promise<void> {
202
const session = this._activeSessions.get(sessionId);
203
if (!session || session.buffer.length === 0) {
204
return;
205
}
206
207
// Drain the buffer and chain on any in-flight flush to serialize writes
208
const lines = session.buffer.splice(0);
209
const content = lines.join('');
210
211
session.flushPromise = session.flushPromise.then(
212
() => this._writeToFile(session, content),
213
() => this._writeToFile(session, content), // still write even if prior flush failed
214
);
215
return session.flushPromise;
216
}
217
218
async endSession(sessionId: string): Promise<void> {
219
await this.flush(sessionId);
220
this._activeSessions.delete(sessionId);
221
}
222
223
getTranscriptPath(sessionId: string): URI | undefined {
224
return this._activeSessions.get(sessionId)?.uri;
225
}
226
227
getLineCount(sessionId: string): number | undefined {
228
return this._activeSessions.get(sessionId)?.lineCount;
229
}
230
231
isTranscriptUri(uri: URI): boolean {
232
const dir = this._getTranscriptsDir();
233
if (!dir) {
234
return false;
235
}
236
return extUriBiasedIgnorePathCase.isEqualOrParent(uri, dir);
237
}
238
239
async cleanupOldTranscripts(maxRetained: number = DEFAULT_MAX_RETAINED): Promise<void> {
240
const dir = this._getTranscriptsDir();
241
if (!dir) {
242
return;
243
}
244
245
try {
246
const entries = await this._fileSystemService.readDirectory(dir);
247
const jsonlFiles = entries
248
.filter(([name, type]) => name.endsWith('.jsonl') && type === 1 /* FileType.File */);
249
250
if (jsonlFiles.length <= maxRetained) {
251
return;
252
}
253
254
// Get stats for sorting by modification time
255
const fileStats = await Promise.all(
256
jsonlFiles.map(async ([name]) => {
257
const fileUri = URI.joinPath(dir, name);
258
const sessionIdFromFile = name.replace('.jsonl', '');
259
try {
260
const stat = await this._fileSystemService.stat(fileUri);
261
return { name, uri: fileUri, mtime: stat.mtime, sessionId: sessionIdFromFile };
262
} catch {
263
return { name, uri: fileUri, mtime: 0, sessionId: sessionIdFromFile };
264
}
265
})
266
);
267
268
// Sort oldest first
269
fileStats.sort((a, b) => a.mtime - b.mtime);
270
271
// Delete oldest, keeping maxRetained and any active sessions
272
const toDelete = fileStats.length - maxRetained;
273
let deleted = 0;
274
for (const file of fileStats) {
275
if (deleted >= toDelete) {
276
break;
277
}
278
if (this._activeSessions.has(file.sessionId)) {
279
continue;
280
}
281
try {
282
await this._fileSystemService.delete(file.uri);
283
deleted++;
284
} catch (err) {
285
this._logService.warn(`[SessionTranscript] Failed to delete old transcript: ${file.name}`);
286
}
287
}
288
} catch {
289
// Directory may not exist yet, that's fine
290
}
291
}
292
293
/**
294
* Replay historical conversation turns into the session buffer.
295
* Each turn produces: user.message → (assistant.turn_start → assistant.message → assistant.turn_end) × N rounds.
296
*/
297
private _replayHistory(sessionId: string, history: readonly IHistoricalTurn[]): void {
298
for (const [turnIndex, turn] of history.entries()) {
299
const turnTimestamp = new Date(turn.timestamp).toISOString();
300
301
this._bufferEntry(sessionId, {
302
type: 'user.message',
303
data: {
304
content: turn.userMessage,
305
attachments: [],
306
},
307
}, turnTimestamp);
308
309
for (const [roundIndex, round] of turn.rounds.entries()) {
310
const roundTimestamp = round.timestamp
311
? new Date(round.timestamp).toISOString()
312
: turnTimestamp;
313
const turnId = `${turnIndex}.${roundIndex}`;
314
315
this._bufferEntry(sessionId, {
316
type: 'assistant.turn_start',
317
data: { turnId },
318
}, roundTimestamp);
319
320
const toolRequests: ToolRequest[] = round.toolCalls.map(tc => ({
321
toolCallId: tc.id,
322
name: tc.name,
323
arguments: tc.arguments,
324
type: 'function' as const,
325
}));
326
327
this._bufferEntry(sessionId, {
328
type: 'assistant.message',
329
data: {
330
messageId: generateUuid(),
331
content: round.response,
332
toolRequests,
333
...(round.reasoningText !== undefined ? { reasoningText: round.reasoningText } : {}),
334
},
335
}, roundTimestamp);
336
337
this._bufferEntry(sessionId, {
338
type: 'assistant.turn_end',
339
data: { turnId },
340
}, roundTimestamp);
341
}
342
}
343
}
344
345
/**
346
* Synchronously buffer a transcript entry. The entry is serialized to
347
* a JSONL line and appended to the session's in-memory buffer. Call
348
* {@link flush} to write buffered entries to disk.
349
*
350
* @param timestampOverride Optional ISO 8601 timestamp; defaults to now.
351
*/
352
private _bufferEntry(sessionId: string, entry: Omit<TranscriptEntry, 'id' | 'timestamp' | 'parentId'>, timestampOverride?: string): void {
353
const session = this._activeSessions.get(sessionId);
354
if (!session) {
355
return;
356
}
357
358
const id = generateUuid();
359
const fullEntry: TranscriptEntry = {
360
...entry,
361
id,
362
timestamp: timestampOverride ?? new Date().toISOString(),
363
parentId: session.lastEntryId,
364
} as TranscriptEntry;
365
366
session.lastEntryId = id;
367
session.lineCount++;
368
session.buffer.push(JSON.stringify(fullEntry) + '\n');
369
}
370
371
/**
372
* Append pre-serialized JSONL content to the session's transcript file.
373
*/
374
private async _writeToFile(session: IActiveSession, content: string): Promise<void> {
375
try {
376
await fs.promises.appendFile(session.uri.fsPath, content, 'utf-8');
377
} catch (err) {
378
this._logService.error('[SessionTranscript] Failed to write transcript entries', err);
379
}
380
}
381
}
382
383