Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 180 additions & 1 deletion src/core/parser-vscode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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<string, SessionSource>(),
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 = {
Expand Down
Loading