Path: blob/main/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts
13399 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 {7IHistoricalTurn,8ISessionTranscriptService,9ToolRequest,10TranscriptEntry,11} from '../../../platform/chat/common/sessionTranscriptService';12import { IEnvService } from '../../../platform/env/common/envService';13import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';14import { IFileSystemService, createDirectoryIfNotExists } from '../../../platform/filesystem/common/fileSystemService';15import { ILogService } from '../../../platform/log/common/logService';16import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';17import { URI } from '../../../util/vs/base/common/uri';18import { generateUuid } from '../../../util/vs/base/common/uuid';1920const TRANSCRIPT_VERSION = 1;21const TRANSCRIPT_PRODUCER = 'copilot-agent';22const DEFAULT_MAX_RETAINED = 20;2324/**25* Strip the internal `__vscode-<number>` suffix that is appended to tool-call26* IDs for uniqueness inside VS Code. The transcript should contain only the27* original model-generated ID.28*/29function stripInternalToolCallId(id: string): string {30return id.split('__vscode-')[0];31}3233interface IActiveSession {34readonly uri: URI;35lastEntryId: string | null;36/** Buffered JSONL lines waiting to be flushed to disk. */37readonly buffer: string[];38/** Chain of flush operations to serialize writes. */39flushPromise: Promise<void>;40/** Running count of lines in the transcript (flushed + buffered). */41lineCount: number;42}4344export class SessionTranscriptService implements ISessionTranscriptService {45declare readonly _serviceBrand: undefined;4647private readonly _activeSessions = new Map<string, IActiveSession>();48private _transcriptsDirUri: URI | undefined;4950constructor(51@IFileSystemService private readonly _fileSystemService: IFileSystemService,52@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext,53@IEnvService private readonly _envService: IEnvService,54@ILogService private readonly _logService: ILogService,55) { }5657private _getTranscriptsDir(): URI | undefined {58if (this._transcriptsDirUri) {59return this._transcriptsDirUri;60}61const storageUri = this._extensionContext.storageUri;62if (!storageUri) {63return undefined;64}65this._transcriptsDirUri = URI.joinPath(storageUri, 'transcripts');66return this._transcriptsDirUri;67}6869async startSession(sessionId: string, context?: { cwd?: string }, history?: readonly IHistoricalTurn[]): Promise<void> {70if (this._activeSessions.has(sessionId)) {71return;72}7374const dir = this._getTranscriptsDir();75if (!dir) {76this._logService.warn('[SessionTranscript] No workspace storage available, transcript will not be written');77return;78}7980try {81await createDirectoryIfNotExists(this._fileSystemService, dir);82} catch (err) {83this._logService.error('[SessionTranscript] Failed to create transcripts directory', err);84return;85}8687const fileUri = URI.joinPath(dir, `${sessionId}.jsonl`);88const session: IActiveSession = {89uri: fileUri,90lastEntryId: null,91buffer: [],92flushPromise: Promise.resolve(),93lineCount: 0,94};95this._activeSessions.set(sessionId, session);9697// If the file already exists on disk, skip history replay and just pick up from here98let fileAlreadyExists = false;99try {100await this._fileSystemService.stat(fileUri);101fileAlreadyExists = true;102} catch {103// File doesn't exist yet104}105106if (fileAlreadyExists) {107// Session file exists — we're resuming; count existing lines so getLineCount stays accurate108try {109const content = await fs.promises.readFile(fileUri.fsPath, 'utf-8');110session.lineCount = content.split('\n').filter(l => l.length > 0).length;111} catch {112}113return;114}115116const startTime = (history && history.length > 0)117? new Date(history[0].timestamp).toISOString()118: new Date().toISOString();119120this._bufferEntry(sessionId, {121type: 'session.start',122data: {123sessionId,124version: TRANSCRIPT_VERSION,125producer: TRANSCRIPT_PRODUCER,126copilotVersion: this._envService.getVersion(),127vscodeVersion: this._envService.vscodeVersion,128startTime,129context,130},131});132133// Replay historical turns if provided134if (history) {135this._replayHistory(sessionId, history);136}137138// Fire-and-forget cleanup of old transcripts139this.cleanupOldTranscripts().catch(() => { });140}141142logUserMessage(sessionId: string, content: string, attachments?: readonly unknown[]): void {143this._bufferEntry(sessionId, {144type: 'user.message',145data: {146content,147attachments: attachments ?? [],148},149});150}151152logAssistantTurnStart(sessionId: string, turnId: string): void {153this._bufferEntry(sessionId, {154type: 'assistant.turn_start',155data: { turnId },156});157}158159logAssistantMessage(sessionId: string, content: string, toolRequests: readonly ToolRequest[], reasoningText?: string): void {160this._bufferEntry(sessionId, {161type: 'assistant.message',162data: {163messageId: generateUuid(),164content,165toolRequests: toolRequests.map(tr => ({ ...tr, toolCallId: stripInternalToolCallId(tr.toolCallId) })),166...(reasoningText !== undefined ? { reasoningText } : {}),167},168});169}170171logToolExecutionStart(sessionId: string, toolCallId: string, toolName: string, args: unknown): void {172this._bufferEntry(sessionId, {173type: 'tool.execution_start',174data: {175toolCallId: stripInternalToolCallId(toolCallId),176toolName,177arguments: args,178},179});180}181182logToolExecutionComplete(sessionId: string, toolCallId: string, success: boolean, resultContent?: string): void {183this._bufferEntry(sessionId, {184type: 'tool.execution_complete',185data: {186toolCallId: stripInternalToolCallId(toolCallId),187success,188...(resultContent !== undefined ? { result: { content: resultContent } } : {}),189},190});191}192193logAssistantTurnEnd(sessionId: string, turnId: string): void {194this._bufferEntry(sessionId, {195type: 'assistant.turn_end',196data: { turnId },197});198}199200async flush(sessionId: string): Promise<void> {201const session = this._activeSessions.get(sessionId);202if (!session || session.buffer.length === 0) {203return;204}205206// Drain the buffer and chain on any in-flight flush to serialize writes207const lines = session.buffer.splice(0);208const content = lines.join('');209210session.flushPromise = session.flushPromise.then(211() => this._writeToFile(session, content),212() => this._writeToFile(session, content), // still write even if prior flush failed213);214return session.flushPromise;215}216217async endSession(sessionId: string): Promise<void> {218await this.flush(sessionId);219this._activeSessions.delete(sessionId);220}221222getTranscriptPath(sessionId: string): URI | undefined {223return this._activeSessions.get(sessionId)?.uri;224}225226getLineCount(sessionId: string): number | undefined {227return this._activeSessions.get(sessionId)?.lineCount;228}229230isTranscriptUri(uri: URI): boolean {231const dir = this._getTranscriptsDir();232if (!dir) {233return false;234}235return extUriBiasedIgnorePathCase.isEqualOrParent(uri, dir);236}237238async cleanupOldTranscripts(maxRetained: number = DEFAULT_MAX_RETAINED): Promise<void> {239const dir = this._getTranscriptsDir();240if (!dir) {241return;242}243244try {245const entries = await this._fileSystemService.readDirectory(dir);246const jsonlFiles = entries247.filter(([name, type]) => name.endsWith('.jsonl') && type === 1 /* FileType.File */);248249if (jsonlFiles.length <= maxRetained) {250return;251}252253// Get stats for sorting by modification time254const fileStats = await Promise.all(255jsonlFiles.map(async ([name]) => {256const fileUri = URI.joinPath(dir, name);257const sessionIdFromFile = name.replace('.jsonl', '');258try {259const stat = await this._fileSystemService.stat(fileUri);260return { name, uri: fileUri, mtime: stat.mtime, sessionId: sessionIdFromFile };261} catch {262return { name, uri: fileUri, mtime: 0, sessionId: sessionIdFromFile };263}264})265);266267// Sort oldest first268fileStats.sort((a, b) => a.mtime - b.mtime);269270// Delete oldest, keeping maxRetained and any active sessions271const toDelete = fileStats.length - maxRetained;272let deleted = 0;273for (const file of fileStats) {274if (deleted >= toDelete) {275break;276}277if (this._activeSessions.has(file.sessionId)) {278continue;279}280try {281await this._fileSystemService.delete(file.uri);282deleted++;283} catch (err) {284this._logService.warn(`[SessionTranscript] Failed to delete old transcript: ${file.name}`);285}286}287} catch {288// Directory may not exist yet, that's fine289}290}291292/**293* Replay historical conversation turns into the session buffer.294* Each turn produces: user.message → (assistant.turn_start → assistant.message → assistant.turn_end) × N rounds.295*/296private _replayHistory(sessionId: string, history: readonly IHistoricalTurn[]): void {297for (const [turnIndex, turn] of history.entries()) {298const turnTimestamp = new Date(turn.timestamp).toISOString();299300this._bufferEntry(sessionId, {301type: 'user.message',302data: {303content: turn.userMessage,304attachments: [],305},306}, turnTimestamp);307308for (const [roundIndex, round] of turn.rounds.entries()) {309const roundTimestamp = round.timestamp310? new Date(round.timestamp).toISOString()311: turnTimestamp;312const turnId = `${turnIndex}.${roundIndex}`;313314this._bufferEntry(sessionId, {315type: 'assistant.turn_start',316data: { turnId },317}, roundTimestamp);318319const toolRequests: ToolRequest[] = round.toolCalls.map(tc => ({320toolCallId: tc.id,321name: tc.name,322arguments: tc.arguments,323type: 'function' as const,324}));325326this._bufferEntry(sessionId, {327type: 'assistant.message',328data: {329messageId: generateUuid(),330content: round.response,331toolRequests,332...(round.reasoningText !== undefined ? { reasoningText: round.reasoningText } : {}),333},334}, roundTimestamp);335336this._bufferEntry(sessionId, {337type: 'assistant.turn_end',338data: { turnId },339}, roundTimestamp);340}341}342}343344/**345* Synchronously buffer a transcript entry. The entry is serialized to346* a JSONL line and appended to the session's in-memory buffer. Call347* {@link flush} to write buffered entries to disk.348*349* @param timestampOverride Optional ISO 8601 timestamp; defaults to now.350*/351private _bufferEntry(sessionId: string, entry: Omit<TranscriptEntry, 'id' | 'timestamp' | 'parentId'>, timestampOverride?: string): void {352const session = this._activeSessions.get(sessionId);353if (!session) {354return;355}356357const id = generateUuid();358const fullEntry: TranscriptEntry = {359...entry,360id,361timestamp: timestampOverride ?? new Date().toISOString(),362parentId: session.lastEntryId,363} as TranscriptEntry;364365session.lastEntryId = id;366session.lineCount++;367session.buffer.push(JSON.stringify(fullEntry) + '\n');368}369370/**371* Append pre-serialized JSONL content to the session's transcript file.372*/373private async _writeToFile(session: IActiveSession, content: string): Promise<void> {374try {375await fs.promises.appendFile(session.uri.fsPath, content, 'utf-8');376} catch (err) {377this._logService.error('[SessionTranscript] Failed to write transcript entries', err);378}379}380}381382383