agent-ecosystem/test/main/utils/jsonl.test.ts

280 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
import {
analyzeSessionFileMetadata,
calculateMetrics,
parseJsonlFile,
parseJsonlLine,
} from '../../../src/main/utils/jsonl';
import type { ParsedMessage } from '../../../src/main/types';
// Helper to create a minimal ParsedMessage
function createMessage(overrides: Partial<ParsedMessage> = {}): ParsedMessage {
return {
uuid: 'test-uuid',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2024-01-01T10:00:00Z'),
content: '',
isSidechain: false,
isMeta: false,
isCompactSummary: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
describe('jsonl', () => {
describe('calculateMetrics', () => {
it('should return empty metrics for empty messages array', () => {
const result = calculateMetrics([]);
expect(result.durationMs).toBe(0);
expect(result.totalTokens).toBe(0);
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(0);
expect(result.messageCount).toBe(0);
});
it('should calculate total tokens from usage', () => {
const messages = [
createMessage({
usage: {
input_tokens: 100,
output_tokens: 50,
},
}),
];
const result = calculateMetrics(messages);
expect(result.inputTokens).toBe(100);
expect(result.outputTokens).toBe(50);
expect(result.totalTokens).toBe(150);
});
it('should sum tokens across multiple messages', () => {
const messages = [
createMessage({
usage: { input_tokens: 100, output_tokens: 50 },
}),
createMessage({
usage: { input_tokens: 200, output_tokens: 100 },
}),
];
const result = calculateMetrics(messages);
expect(result.inputTokens).toBe(300);
expect(result.outputTokens).toBe(150);
expect(result.totalTokens).toBe(450);
});
it('should handle cache tokens', () => {
const messages = [
createMessage({
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 25,
cache_creation_input_tokens: 10,
},
}),
];
const result = calculateMetrics(messages);
expect(result.cacheReadTokens).toBe(25);
expect(result.cacheCreationTokens).toBe(10);
expect(result.totalTokens).toBe(185); // 100 + 50 + 25 + 10
});
it('should calculate duration from timestamps', () => {
const messages = [
createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') }),
createMessage({ timestamp: new Date('2024-01-01T10:01:00Z') }),
createMessage({ timestamp: new Date('2024-01-01T10:02:00Z') }),
];
const result = calculateMetrics(messages);
expect(result.durationMs).toBe(120000); // 2 minutes in ms
});
it('should count messages', () => {
const messages = [createMessage(), createMessage(), createMessage()];
const result = calculateMetrics(messages);
expect(result.messageCount).toBe(3);
});
it('should handle messages without usage', () => {
const messages = [
createMessage({ type: 'user', content: 'Hello' }),
createMessage({ type: 'system' }),
];
const result = calculateMetrics(messages);
expect(result.totalTokens).toBe(0);
expect(result.messageCount).toBe(2);
});
it('should handle single message duration', () => {
const messages = [createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') })];
const result = calculateMetrics(messages);
expect(result.durationMs).toBe(0); // min === max
});
it('should handle undefined token values', () => {
const messages = [
createMessage({
usage: {
input_tokens: undefined as unknown as number,
output_tokens: 50,
},
}),
];
const result = calculateMetrics(messages);
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(50);
});
});
describe('analyzeSessionFileMetadata', () => {
it('should extract first message, count, ongoing state, and git branch in one pass', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-'));
try {
const filePath = path.join(tempDir, 'session.jsonl');
const lines = [
JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2026-01-01T00:00:00.000Z',
gitBranch: 'feature/test',
message: { role: 'user', content: 'hello world' },
isMeta: false,
}),
JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2026-01-01T00:00:01.000Z',
message: {
role: 'assistant',
content: [{ type: 'thinking', thinking: 'thinking...' }],
},
}),
];
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
const result = await analyzeSessionFileMetadata(filePath);
expect(result.firstUserMessage?.text).toBe('hello world');
expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z');
expect(result.messageCount).toBe(2);
expect(result.isOngoing).toBe(true);
expect(result.gitBranch).toBe('feature/test');
} finally {
try {
fs.rmSync(tempDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 200,
});
} catch {
// Best-effort cleanup; ignore ENOTEMPTY on Windows when dir is in use
}
}
});
});
describe('tolerant parsing', () => {
it('skips non-JSON garbage and ignores a partial trailing object', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-'));
try {
const filePath = path.join(tempDir, 'session.jsonl');
const validLine = JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2026-01-01T00:00:01.000Z',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hello' }],
},
});
const nonJsonGarbage = '╨╕┬аAI-╤А╨░╨╖╤А╨░╨▒';
const partialJson =
'{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"';
fs.writeFileSync(filePath, `${validLine}\n${nonJsonGarbage}\n${partialJson}`, 'utf8');
const result = await parseJsonlFile(filePath);
expect(result).toHaveLength(1);
expect(result[0]?.uuid).toBe('a1');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('strips a UTF-8 BOM before parsing an object line', () => {
const parsed = parseJsonlLine(
`\ufeff${JSON.stringify({
type: 'assistant',
uuid: 'bom-1',
timestamp: '2026-01-01T00:00:01.000Z',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'bom' }],
},
})}`
);
expect(parsed?.uuid).toBe('bom-1');
});
it('preserves real transcript metadata needed by task-log fallback selection', () => {
const parsed = parseJsonlLine(
JSON.stringify({
parentUuid: 'assistant-1',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-real-1',
version: '1.0.0',
gitBranch: 'main',
type: 'user',
uuid: 'user-real-1',
timestamp: '2026-04-12T15:36:14.250Z',
agentName: 'tom',
isMeta: true,
sourceToolAssistantUUID: 'assistant-1',
sourceToolUseID: 'call-bash-real',
toolUseResult: {
toolUseId: 'call-bash-real',
stdout: 'tests ok',
exitCode: 0,
},
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call-bash-real',
content: 'tests ok',
},
],
},
}),
);
expect(parsed?.sessionId).toBe('session-real-1');
expect(parsed?.agentName).toBe('tom');
expect(parsed?.isMeta).toBe(true);
expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1');
expect(parsed?.sourceToolUseID).toBe('call-bash-real');
expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real');
});
});
});