From 7fd40b9dadba1e2528c3220103f4ed36379e5059 Mon Sep 17 00:00:00 2001 From: "Horatiu A." Date: Sun, 28 Jun 2026 14:34:38 +0300 Subject: [PATCH] feat(parser): ingest transcripts and dedupe by session id - Parse transcript files into turns and tool metadata - Merge transcript sessions with existing chatSessions by normalized sessionId - Add coverage for transcript parsing, dedup, and transcript-only ingestion - Keep workspace parsing behavior aligned with stripped response text fields Refs #87 --- src/core/parser-vscode.test.ts | 181 +++++++++++++++++++- src/core/parser-vscode.ts | 303 +++++++++++++++++++++++++++++++-- 2 files changed, 469 insertions(+), 15 deletions(-) diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index c9f37ccd..c3ea39e5 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -7,9 +7,11 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { describe, it, expect } from 'vitest'; +import { SessionSource } from './cache'; +import { ParseContext } from './parser-shared'; import { reconstructFromJsonl } from './parser-vscode-files'; import { parseCLIEventsFile } from './parser-vscode-cli'; -import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; +import { parseSessionFile, parseTranscriptFile, processWorkspaceEntry, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; function withTempFile(name: string, content: string, run: (filePath: string) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-')); @@ -614,6 +616,183 @@ describe('scanVsCodeDirs', () => { }); }); +describe('parseTranscriptFile', () => { + it('converts transcript events into a session with user/assistant turns', () => { + const lines = [ + JSON.stringify({ + type: 'session.start', + id: 'ev-1', + timestamp: '2026-05-15T08:52:24.985Z', + data: { sessionId: 'transcript-session-1' }, + }), + JSON.stringify({ + type: 'user.message', + id: 'ev-2', + timestamp: '2026-05-15T08:52:30.000Z', + data: { content: 'Explain the parser architecture.' }, + }), + JSON.stringify({ + type: 'assistant.message', + id: 'ev-3', + timestamp: '2026-05-15T08:52:35.000Z', + data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'list_dir', type: 'function' }] }, + }), + JSON.stringify({ + type: 'tool.execution_start', + id: 'ev-4', + timestamp: '2026-05-15T08:52:35.100Z', + data: { toolCallId: 'tc-1', toolName: 'list_dir' }, + }), + JSON.stringify({ + type: 'assistant.message', + id: 'ev-6', + timestamp: '2026-05-15T08:52:38.000Z', + data: { content: 'The parser reads JSONL files line by line.', toolRequests: [] }, + }), + ].join('\n'); + + withTempFile('transcript-1.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-1', 'my-project', 'Local Agent (Server)'); + expect(session).not.toBeNull(); + expect(session!.sessionId).toBe('transcript-session-1'); + expect(session!.workspaceId).toBe('ws-1'); + expect(session!.workspaceName).toBe('my-project'); + expect(session!.harness).toBe('Local Agent (Server)'); + expect(session!.requests).toHaveLength(1); + expect(session!.requests[0].messageText).toBe('Explain the parser architecture.'); + expect(session!.requests[0].responseText).toBe('The parser reads JSONL files line by line.'); + expect(session!.requests[0].toolsUsed).toEqual(['list_dir']); + }); + }); + + it('groups multiple user turns into separate requests', () => { + const lines = [ + JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'multi-turn' } }), + JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'First question.' } }), + JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z', data: { content: 'First answer.' } }), + JSON.stringify({ type: 'user.message', id: 'e3', timestamp: '2026-05-15T09:00:05.000Z', data: { content: 'Second question.' } }), + JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:06.000Z', data: { content: 'Second answer.' } }), + ].join('\n'); + + withTempFile('transcript-multi.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-2', 'proj', 'Local Agent (Server)'); + expect(session).not.toBeNull(); + expect(session!.requests).toHaveLength(2); + expect(session!.requests[0].messageText).toBe('First question.'); + expect(session!.requests[1].messageText).toBe('Second question.'); + }); + }); + + it('returns null for empty or malformed transcript files', () => { + withTempFile('transcript-empty.jsonl', '', (filePath) => { + expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull(); + }); + withTempFile('transcript-corrupt.jsonl', 'not json\nalso not json\n', (filePath) => { + expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull(); + }); + }); + + it('deduplicates tool names collected from toolRequests and tool.execution_start', () => { + const lines = [ + JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'dedup-tools' } }), + JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'Do work.' } }), + JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z', data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'read_file' }] } }), + JSON.stringify({ type: 'tool.execution_start', id: 'e3', timestamp: '2026-05-15T09:00:03.000Z', data: { toolCallId: 'tc-1', toolName: 'read_file' } }), + JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:04.000Z', data: { content: 'Done.' } }), + ].join('\n'); + + withTempFile('transcript-dedup.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-4', 'proj', 'Local Agent (Server)'); + expect(session).not.toBeNull(); + expect(session!.requests[0].toolsUsed).toEqual(['read_file']); + }); + }); +}); + +describe('processWorkspaceEntry — transcript dedup', () => { + function makeParseContext(): ParseContext { + return { + workspaces: new Map(), + sessions: [], + editLocIndex: new Map(), + sessionSourceIndex: new Map(), + aiLoc: 0, + }; + } + + it('emits one session when chatSessions and transcript share sessionId', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-dup-')); + try { + const wsId = 'workspace-a'; + const entryPath = path.join(root, wsId); + const chatDir = path.join(entryPath, 'chatSessions'); + const transcriptDir = path.join(entryPath, 'GitHub.copilot-chat', 'transcripts'); + fs.mkdirSync(chatDir, { recursive: true }); + fs.mkdirSync(transcriptDir, { recursive: true }); + + const chatJson = { + sessionId: 'same-session', + creationDate: 1700000000000, + lastMessageDate: 1700000001000, + requests: [{ + requestId: 'r1', + timestamp: 1700000001000, + message: { text: 'hello' }, + response: [{ value: 'from chat' }], + result: { metadata: {} }, + }], + }; + fs.writeFileSync(path.join(chatDir, 'session.json'), JSON.stringify(chatJson), 'utf-8'); + + const transcriptLines = [ + JSON.stringify({ type: 'session.start', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'same-session' } }), + JSON.stringify({ type: 'user.message', id: 'u1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'hello' } }), + JSON.stringify({ type: 'assistant.message', id: 'a1', timestamp: '2026-05-15T09:00:02.000Z', data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'read_file' }] } }), + JSON.stringify({ type: 'tool.execution_start', id: 't1', timestamp: '2026-05-15T09:00:03.000Z', data: { toolCallId: 'tc-1', toolName: 'read_file' } }), + ].join('\n'); + fs.writeFileSync(path.join(transcriptDir, 'same-session.jsonl'), transcriptLines, 'utf-8'); + + const ctx = makeParseContext(); + processWorkspaceEntry(root, wsId, 'Local Agent (Server)', ctx); + + expect(ctx.sessions).toHaveLength(1); + expect(ctx.sessions[0].sessionId).toBe('same-session'); + expect(ctx.sessions[0].requests[0].responseLength).toBeGreaterThan(0); + expect(ctx.sessions[0].requests[0].toolsUsed).toEqual(['read_file']); + const source = ctx.sessionSourceIndex.get('same-session'); + expect(source?.filePath.endsWith(path.join('chatSessions', 'session.json'))).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('ingests transcript-only sessions when chat session is missing', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-transcript-only-')); + try { + const wsId = 'workspace-b'; + const entryPath = path.join(root, wsId); + const transcriptDir = path.join(entryPath, 'GitHub.copilot-chat', 'transcripts'); + fs.mkdirSync(transcriptDir, { recursive: true }); + + const transcriptLines = [ + JSON.stringify({ type: 'session.start', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'transcript-only' } }), + JSON.stringify({ type: 'user.message', id: 'u1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'hello' } }), + JSON.stringify({ type: 'assistant.message', id: 'a1', timestamp: '2026-05-15T09:00:02.000Z', data: { content: 'from transcript' } }), + ].join('\n'); + fs.writeFileSync(path.join(transcriptDir, 'transcript-only.jsonl'), transcriptLines, 'utf-8'); + + const ctx = makeParseContext(); + processWorkspaceEntry(root, wsId, 'Local Agent (Server)', ctx); + + expect(ctx.sessions).toHaveLength(1); + expect(ctx.sessions[0].sessionId).toBe('transcript-only'); + expect(ctx.sessions[0].requests[0].responseLength).toBeGreaterThan(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('parseSessionFile — skill detection', () => { it('detects skills from promptFile variables pointing to SKILL.md', () => { const data = { diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index 6795d9e9..55b1eefc 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -7,8 +7,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Session } from './types'; -import { createSession, detectDevcontainerFromRequests, ParseContext, prefetchCache, stripSingleSession, maybeForceGc } from './parser-shared'; +import { Session, SessionRequest } from './types'; +import { createRequest, createSession, detectDevcontainerFromRequests, ParseContext, prefetchCache, stripSingleSession, maybeForceGc } from './parser-shared'; import { debugCore, warnCore } from './log'; import { canonicalizeReasoningEffort } from './helpers'; import { parseRawRequest, normalizeSessionMode, type RawRequest } from './parser-vscode-request'; @@ -153,6 +153,190 @@ function listEditStateFiles(esDir: string): string[] { } } +function listTranscriptFiles(transcriptDir: string): string[] { + try { + return fs.readdirSync(transcriptDir, { withFileTypes: true }) + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => path.join(transcriptDir, e.name)); + } catch { + return []; + } +} + +interface TranscriptEvent { + type: string; + id?: string; + timestamp?: string; + data?: Record; +} + +function parseTranscriptLines(raw: string): TranscriptEvent[] { + const events: TranscriptEvent[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { type?: unknown }).type === 'string') { + events.push(parsed as TranscriptEvent); + } + } catch { + // Skip malformed transcript rows; keep the rest of the session. + } + } + return events; +} + +function buildToolNameIndex(events: TranscriptEvent[]): Map { + const byCallId = new Map(); + for (const ev of events) { + if (ev.type !== 'tool.execution_start') continue; + const callId = typeof ev.data?.toolCallId === 'string' ? ev.data.toolCallId : ''; + const toolName = typeof ev.data?.toolName === 'string' ? ev.data.toolName : ''; + if (!callId || !toolName) continue; + byCallId.set(callId, toolName); + } + return byCallId; +} + +function collectToolsFromToolRequests( + toolRequests: unknown, + toolNameByCallId: Map, + out: string[], +): void { + if (!Array.isArray(toolRequests)) return; + for (const req of toolRequests) { + if (typeof req !== 'object' || req === null) continue; + const rec = req as { name?: unknown; toolName?: unknown; toolCallId?: unknown }; + const explicit = typeof rec.name === 'string' ? rec.name : (typeof rec.toolName === 'string' ? rec.toolName : ''); + if (explicit) { + out.push(explicit); + continue; + } + const callId = typeof rec.toolCallId === 'string' ? rec.toolCallId : ''; + if (!callId) continue; + const fallback = toolNameByCallId.get(callId); + if (fallback) out.push(fallback); + } +} + +function buildRequestsFromTranscriptEvents( + events: TranscriptEvent[], + toolNameByCallId: Map, +): SessionRequest[] { + const requests: SessionRequest[] = []; + let currentUserMessage: string | null = null; + let currentUserTs: number | null = null; + let currentUserMessageId: string | null = null; + let responseParts: string[] = []; + let toolsUsed: string[] = []; + + const flushTurn = () => { + if (currentUserMessage === null) return; + requests.push(createRequest({ + requestId: currentUserMessageId ?? '', + timestamp: currentUserTs, + messageText: currentUserMessage, + responseText: responseParts.join('').trim(), + toolsUsed: [...new Set(toolsUsed)], + agentMode: 'agent', + })); + currentUserMessage = null; + currentUserTs = null; + currentUserMessageId = null; + responseParts = []; + toolsUsed = []; + }; + + for (const ev of events) { + if (ev.type === 'user.message') { + flushTurn(); + currentUserMessage = typeof ev.data?.content === 'string' ? ev.data.content : ''; + currentUserTs = ev.timestamp ? new Date(ev.timestamp).getTime() : null; + currentUserMessageId = ev.id ?? null; + continue; + } + + if (currentUserMessage === null) continue; + + if (ev.type === 'assistant.message') { + const content = typeof ev.data?.content === 'string' ? ev.data.content : ''; + if (content) responseParts.push(content); + collectToolsFromToolRequests(ev.data?.toolRequests, toolNameByCallId, toolsUsed); + continue; + } + + if (ev.type === 'tool.execution_start') { + const toolName = typeof ev.data?.toolName === 'string' ? ev.data.toolName : ''; + if (toolName) toolsUsed.push(toolName); + } + } + + flushTurn(); + return requests; +} + +function normalizeSessionId(raw: string | null | undefined): string { + return (raw ?? '').trim(); +} + +function unionStringArraysStable(base: string[], incoming: string[]): string[] { + if (incoming.length === 0) return base; + const out = [...base]; + const seen = new Set(base); + for (const item of incoming) { + if (!item || seen.has(item)) continue; + seen.add(item); + out.push(item); + } + return out; +} + +function mergeTranscriptIntoSession(base: Session, transcript: Session): void { + if (base.requests.length === transcript.requests.length) { + for (let i = 0; i < base.requests.length; i++) { + base.requests[i].toolsUsed = unionStringArraysStable(base.requests[i].toolsUsed, transcript.requests[i].toolsUsed); + } + } + if (base.creationDate == null && transcript.creationDate != null) { + base.creationDate = transcript.creationDate; + } + if (base.lastMessageDate == null && transcript.lastMessageDate != null) { + base.lastMessageDate = transcript.lastMessageDate; + } +} + +function addSessionIfNew( + sessions: Session[], + byId: Map, + session: Session, +): boolean { + const id = normalizeSessionId(session.sessionId); + if (!id || byId.has(id)) return false; + session.sessionId = id; + byId.set(id, sessions.length); + sessions.push(session); + return true; +} + +function addOrMergeTranscriptSession( + sessions: Session[], + byId: Map, + transcript: Session, +): boolean { + const id = normalizeSessionId(transcript.sessionId); + if (!id) return false; + const existingIdx = byId.get(id); + if (existingIdx === undefined) { + transcript.sessionId = id; + byId.set(id, sessions.length); + sessions.push(transcript); + return true; + } + mergeTranscriptIntoSession(sessions[existingIdx], transcript); + return false; +} + function countLinesAdded(edits: { text?: string }[] | undefined): number { let linesAdded = 0; for (const edit of (edits || [])) { @@ -244,6 +428,7 @@ export function processWorkspaceEntry( const { workspaces, sessions, editLocIndex, sessionSourceIndex } = ctx; const startIdx = sessions.length; const { entryPath, wsName, isCLI, customInstructionsBytes } = initializeWorkspaceEntry(logsDir, wsId, harness, workspaces); + const sessionIndexById = new Map(); if (isCLI) { const eventsFile = path.join(entryPath, 'events.jsonl'); @@ -265,8 +450,7 @@ export function processWorkspaceEntry( const chatDir = path.join(entryPath, 'chatSessions'); for (const sessionFile of listChatSessionFiles(chatDir)) { const session = parseSessionFile(sessionFile, wsId, wsName, harness, customInstructionsBytes); - if (session) { - sessions.push(session); + if (session && addSessionIfNew(sessions, sessionIndexById, session)) { sessionSourceIndex.set(session.sessionId, { kind: 'vscode-session-file', filePath: sessionFile, @@ -277,6 +461,20 @@ export function processWorkspaceEntry( } } + const transcriptDir = path.join(entryPath, 'GitHub.copilot-chat', 'transcripts'); + for (const transcriptFile of listTranscriptFiles(transcriptDir)) { + const transcriptSession = parseTranscriptFile(transcriptFile, wsId, wsName, harness, customInstructionsBytes); + if (transcriptSession && addOrMergeTranscriptSession(sessions, sessionIndexById, transcriptSession)) { + sessionSourceIndex.set(transcriptSession.sessionId, { + kind: 'vscode-session-file', + filePath: transcriptFile, + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } + } + const eventsFile = path.join(entryPath, 'events.jsonl'); const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); if (cliSession) { @@ -311,6 +509,7 @@ export async function processWorkspaceEntryAsync( const { workspaces, sessions, editLocIndex, sessionSourceIndex } = ctx; const startIdx = sessions.length; const { entryPath, wsName, isCLI, customInstructionsBytes } = initializeWorkspaceEntry(logsDir, wsId, harness, workspaces); + const sessionIndexById = new Map(); if (isCLI) { const eventsFile = path.join(entryPath, 'events.jsonl'); @@ -346,9 +545,10 @@ export async function processWorkspaceEntryAsync( } const chatFiles = listChatSessionFiles(path.join(entryPath, 'chatSessions')); + const transcriptFiles = listTranscriptFiles(path.join(entryPath, 'GitHub.copilot-chat', 'transcripts')); const editStateFiles = listEditStateFiles(path.join(entryPath, 'chatEditingSessions')); - const totalUnits = Math.max(1, chatFiles.length + editStateFiles.length); - const chatEvery = chunkInterval(chatFiles.length); + const totalUnits = Math.max(1, chatFiles.length + transcriptFiles.length + editStateFiles.length); + const chatEvery = chunkInterval(chatFiles.length + transcriptFiles.length); const editEvery = chunkInterval(editStateFiles.length); let completed = 0; @@ -358,14 +558,15 @@ export async function processWorkspaceEntryAsync( // Strip heavy text the moment a session is parsed so a workspace with many large // sessions can't accumulate its full text before the workspace finishes (issue #106). stripSingleSession(session); - sessions.push(session); - sessionSourceIndex.set(session.sessionId, { - kind: 'vscode-session-file', - filePath: chatFiles[i], - workspaceId: wsId, - workspaceName: wsName, - harness, - }); + if (addSessionIfNew(sessions, sessionIndexById, session)) { + sessionSourceIndex.set(session.sessionId, { + kind: 'vscode-session-file', + filePath: chatFiles[i], + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } } completed++; if (shouldReportChunk(i, chatFiles.length, chatEvery)) { @@ -384,6 +585,33 @@ export async function processWorkspaceEntryAsync( maybeForceGc(); } + for (let i = 0; i < transcriptFiles.length; i++) { + const transcriptSession = parseTranscriptFile(transcriptFiles[i], wsId, wsName, harness, customInstructionsBytes); + if (transcriptSession) { + stripSingleSession(transcriptSession); + if (addOrMergeTranscriptSession(sessions, sessionIndexById, transcriptSession)) { + sessionSourceIndex.set(transcriptSession.sessionId, { + kind: 'vscode-session-file', + filePath: transcriptFiles[i], + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } + } + completed++; + if (shouldReportChunk(chatFiles.length + i, chatFiles.length + transcriptFiles.length, chatEvery)) { + onProgress?.({ + wsName, + detail: `transcript ${i + 1}/${transcriptFiles.length}`, + completed, + total: totalUnits, + }); + } + await yieldToLoop(); + maybeForceGc(); + } + const eventsFile = path.join(entryPath, 'events.jsonl'); const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); if (cliSession) { @@ -512,3 +740,50 @@ export function parseSessionFile(sessionFile: string, wsId: string, wsName: stri customInstructionsBytes, }); } + +/** + * Parse VS Code Agent Debug transcripts (`GitHub.copilot-chat/transcripts/*.jsonl`) + * into the same session model used by the rest of the analyzer. + */ +export function parseTranscriptFile( + filePath: string, + wsId: string, + wsName: string, + harness: string, + customInstructionsBytes?: number, +): Session | null { + let raw: string; + try { + raw = readFile(filePath); + } catch (e) { + debugCore('parser-vscode', `Cannot read transcript file ${filePath}`, e); + return null; + } + + const events = parseTranscriptLines(raw); + if (events.length === 0) return null; + + const sessionStart = events.find(e => e.type === 'session.start'); + const sessionId = normalizeSessionId( + typeof sessionStart?.data?.sessionId === 'string' + ? sessionStart.data.sessionId + : path.basename(filePath, '.jsonl'), + ); + if (!sessionId) return null; + + const creationDate = sessionStart?.timestamp ? new Date(sessionStart.timestamp).getTime() : null; + const toolNameByCallId = buildToolNameIndex(events); + const requests = buildRequestsFromTranscriptEvents(events, toolNameByCallId); + if (requests.length === 0) return null; + + return createSession({ + sessionId, + workspaceId: wsId, + workspaceName: wsName, + harness, + location: 'panel', + creationDate, + requests, + customInstructionsBytes, + }); +}