- Updated NeedleEventType union to reflect actual event types emitted by NEEDLE - Fixed outdated event type names (e.g., bead.claimed → bead.claim.succeeded) - Added missing event types (worker.errored, worker.exhausted, peer.stale, etc.) - Updated parser test to use correct event types - Verified parser compatibility with 86,545 actual NEEDLE log events (100% success rate) The parser's normalizeJsonl function accepts any string for event_type, ensuring forward compatibility with new NEEDLE versions. The type definition update is primarily for documentation and IDE support. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2617 lines
75 KiB
TypeScript
2617 lines
75 KiB
TypeScript
/**
|
|
* Tests for FABRIC Log Parser
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
parseNeedleEvent,
|
|
parseLogLine,
|
|
parseLogLines,
|
|
formatEvent,
|
|
isConversationEvent,
|
|
parseConversationEvent,
|
|
parseConversationEvents,
|
|
parseConversationLine,
|
|
parseConversationContent,
|
|
formatConversationEvent,
|
|
} from './parser.js';
|
|
import { LogEvent, LogLevel, ConversationEvent, NeedleEvent, NeedleEventType, NEEDLE_EVENT_SCHEMA_VERSION } from './types.js';
|
|
|
|
describe('parseLogLine', () => {
|
|
describe('valid inputs', () => {
|
|
it('should parse a minimal valid log line', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'info',
|
|
msg: 'Test message',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).toEqual({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'info',
|
|
msg: 'Test message',
|
|
});
|
|
});
|
|
|
|
it('should parse a log line with all optional fields', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'debug',
|
|
msg: 'Tool call',
|
|
tool: 'Read',
|
|
path: '/src/main.ts',
|
|
bead: 'bd-xyz',
|
|
duration_ms: 5000,
|
|
error: 'some error',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).toEqual({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'debug',
|
|
msg: 'Tool call',
|
|
tool: 'Read',
|
|
path: '/src/main.ts',
|
|
bead: 'bd-xyz',
|
|
duration_ms: 5000,
|
|
error: 'some error',
|
|
});
|
|
});
|
|
|
|
it('should preserve additional non-standard fields', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
customField: 'custom value',
|
|
tokens: 150,
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).toMatchObject({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
customField: 'custom value',
|
|
tokens: 150,
|
|
});
|
|
});
|
|
|
|
it('should accept all valid log levels', () => {
|
|
const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
|
|
|
|
for (const level of levels) {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level,
|
|
msg: 'Test',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe(level);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('NEEDLE format', () => {
|
|
it('should parse NEEDLE format with ISO timestamp', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test',
|
|
},
|
|
data: {
|
|
pid: 2789549,
|
|
workspace: '/home/coder/forge',
|
|
agent: 'claude-code-glm-4.7',
|
|
},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.ts).toBe(1772641054008); // Unix ms from '2026-03-04T16:17:34.008Z'
|
|
expect(result?.worker).toBe('claude-code-glm-4.7-test');
|
|
expect(result?.msg).toBe('worker.started');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.session).toBe('forge-glm-test');
|
|
expect(result?.provider).toBe('code');
|
|
expect(result?.model).toBe('glm-4.7');
|
|
});
|
|
|
|
it('should extract bead_id from data payload', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'bead.claimed',
|
|
session: 'test-session',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'sonnet',
|
|
identifier: 'worker1',
|
|
},
|
|
data: {
|
|
bead_id: 'bd-2ok0',
|
|
title: 'Test task',
|
|
workspace: '/home/coder/forge',
|
|
},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.bead).toBe('bd-2ok0');
|
|
});
|
|
|
|
it('should extract duration_ms from data payload', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'bead.completed',
|
|
session: 'test-session',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'anthropic',
|
|
model: 'sonnet',
|
|
identifier: 'test',
|
|
},
|
|
data: {
|
|
bead_id: 'bd-xyz',
|
|
duration_ms: 10076,
|
|
output_file: '/tmp/output.log',
|
|
},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.duration_ms).toBe(10076);
|
|
expect(result?.bead).toBe('bd-xyz');
|
|
});
|
|
|
|
it('should infer error level from event name', () => {
|
|
// NEEDLE rule: events with error.* prefix -> error
|
|
const errorEvents = [
|
|
'error.claim_failed',
|
|
'error.agent_crash',
|
|
'error.timeout',
|
|
];
|
|
|
|
for (const eventName of errorEvents) {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: eventName,
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'test',
|
|
identifier: 'test',
|
|
},
|
|
data: {},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('error');
|
|
}
|
|
});
|
|
|
|
it('should infer warn level from event name', () => {
|
|
// NEEDLE rule: events with *.failed or *.retry suffix -> warn
|
|
const warnEvents = ['bead.failed', 'hook.failed'];
|
|
|
|
for (const eventName of warnEvents) {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: eventName,
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'test',
|
|
identifier: 'test',
|
|
},
|
|
data: {},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('warn');
|
|
}
|
|
});
|
|
|
|
it('should infer debug level from event name', () => {
|
|
// NEEDLE rule: events with debug.* prefix -> debug
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'debug.probe',
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'test',
|
|
identifier: 'test',
|
|
},
|
|
data: {},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('debug');
|
|
});
|
|
|
|
it('should default to info level for normal events', () => {
|
|
const infoEvents = [
|
|
'worker.started',
|
|
'worker.idle',
|
|
'bead.claimed',
|
|
'bead.completed',
|
|
'effort.recorded',
|
|
];
|
|
|
|
for (const eventName of infoEvents) {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: eventName,
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'test',
|
|
identifier: 'test',
|
|
},
|
|
data: {},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('info');
|
|
}
|
|
});
|
|
|
|
it('should preserve additional data fields', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'bead.claimed',
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'test',
|
|
identifier: 'test',
|
|
},
|
|
data: {
|
|
bead_id: 'bd-123',
|
|
title: 'Custom title',
|
|
attempt: 3,
|
|
workspace: '/home/coder/test',
|
|
},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.title).toBe('Custom title');
|
|
expect(result?.attempt).toBe(3);
|
|
expect(result?.workspace).toBe('/home/coder/test');
|
|
});
|
|
|
|
it('should flatten worker to runner-provider-model-identifier format', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: {
|
|
runner: 'needle',
|
|
provider: 'anthropic',
|
|
model: 'opus',
|
|
identifier: 'prod-worker-1',
|
|
},
|
|
data: {},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.worker).toBe('needle-anthropic-opus-prod-worker-1');
|
|
});
|
|
});
|
|
|
|
describe('invalid inputs', () => {
|
|
it('should return null for empty string', () => {
|
|
expect(parseLogLine('')).toBeNull();
|
|
});
|
|
|
|
it('should return null for whitespace-only string', () => {
|
|
expect(parseLogLine(' \n\t ')).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-JSON string', () => {
|
|
expect(parseLogLine('not valid json')).toBeNull();
|
|
});
|
|
|
|
it('should return null for malformed JSON', () => {
|
|
expect(parseLogLine('{"ts": 123,')).toBeNull();
|
|
});
|
|
|
|
it('should return null when ts is missing', () => {
|
|
const line = JSON.stringify({
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when ts is not a number', () => {
|
|
const line = JSON.stringify({
|
|
ts: 'not-a-number',
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when worker is missing', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when worker is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 123,
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when level is missing', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when level is invalid', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'invalid',
|
|
msg: 'Test',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when msg is missing', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
|
|
it('should return null when msg is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: { text: 'nested' },
|
|
});
|
|
|
|
expect(parseLogLine(line)).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseLogLines', () => {
|
|
it('should parse multiple valid log lines', () => {
|
|
const content = [
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'first' }),
|
|
JSON.stringify({ ts: 2, worker: 'w2', level: 'debug', msg: 'second' }),
|
|
JSON.stringify({ ts: 3, worker: 'w3', level: 'warn', msg: 'third' }),
|
|
].join('\n');
|
|
|
|
const results = parseLogLines(content);
|
|
|
|
expect(results).toHaveLength(3);
|
|
expect(results[0].msg).toBe('first');
|
|
expect(results[1].msg).toBe('second');
|
|
expect(results[2].msg).toBe('third');
|
|
});
|
|
|
|
it('should skip invalid lines', () => {
|
|
const content = [
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'valid' }),
|
|
'invalid json',
|
|
JSON.stringify({ ts: 2, worker: 'w2', level: 'info', msg: 'also valid' }),
|
|
].join('\n');
|
|
|
|
const results = parseLogLines(content);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].msg).toBe('valid');
|
|
expect(results[1].msg).toBe('also valid');
|
|
});
|
|
|
|
it('should skip empty lines', () => {
|
|
const content = [
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'first' }),
|
|
'',
|
|
' ',
|
|
JSON.stringify({ ts: 2, worker: 'w2', level: 'info', msg: 'second' }),
|
|
].join('\n');
|
|
|
|
const results = parseLogLines(content);
|
|
|
|
expect(results).toHaveLength(2);
|
|
});
|
|
|
|
it('should return empty array for empty content', () => {
|
|
expect(parseLogLines('')).toEqual([]);
|
|
expect(parseLogLines('\n\n\n')).toEqual([]);
|
|
});
|
|
|
|
it('should handle content with trailing newline', () => {
|
|
const content =
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'test' }) + '\n';
|
|
|
|
const results = parseLogLines(content);
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('formatEvent', () => {
|
|
const baseEvent: LogEvent = {
|
|
ts: 1709337600000, // 2024-03-02 00:00:00 UTC
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test message',
|
|
};
|
|
|
|
it('should format a basic event', () => {
|
|
const formatted = formatEvent(baseEvent);
|
|
|
|
expect(formatted).toContain('w-test');
|
|
expect(formatted).toContain('INFO');
|
|
expect(formatted).toContain('Test message');
|
|
});
|
|
|
|
it('should include timestamp', () => {
|
|
const formatted = formatEvent(baseEvent);
|
|
|
|
// Timestamp should be in HH:MM:SS format
|
|
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
|
|
});
|
|
|
|
it('should hide worker when showWorker is false', () => {
|
|
const formatted = formatEvent(baseEvent, { showWorker: false });
|
|
|
|
// Worker ID should be padded to 12 chars in normal mode
|
|
// In hidden mode, it shouldn't appear
|
|
expect(formatted).not.toContain('w-test');
|
|
});
|
|
|
|
it('should hide level when showLevel is false', () => {
|
|
const formatted = formatEvent(baseEvent, { showLevel: false });
|
|
|
|
expect(formatted).not.toContain('INFO');
|
|
});
|
|
|
|
it('should include tool when present', () => {
|
|
const event: LogEvent = { ...baseEvent, tool: 'Read' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('[Read]');
|
|
});
|
|
|
|
it('should include path when present', () => {
|
|
const event: LogEvent = { ...baseEvent, path: '/src/main.ts' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('/src/main.ts');
|
|
});
|
|
|
|
it('should include bead when present', () => {
|
|
const event: LogEvent = { ...baseEvent, bead: 'bd-xyz' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('bead:bd-xyz');
|
|
});
|
|
|
|
it('should include duration when present', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 5000 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('5.0s');
|
|
});
|
|
|
|
it('should include error when present', () => {
|
|
const event: LogEvent = { ...baseEvent, error: 'Something went wrong' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('ERROR: Something went wrong');
|
|
});
|
|
|
|
it('should format short durations in milliseconds', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 500 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('500ms');
|
|
});
|
|
|
|
it('should format medium durations in seconds', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 5000 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('5.0s');
|
|
});
|
|
|
|
it('should format long durations in minutes and seconds', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 125000 }; // 2m 5s
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('2m 5s');
|
|
});
|
|
|
|
describe('colorization', () => {
|
|
it('should not colorize by default', () => {
|
|
const formatted = formatEvent(baseEvent);
|
|
|
|
expect(formatted).not.toContain('\x1b[');
|
|
});
|
|
|
|
it('should colorize when colorize is true', () => {
|
|
const formatted = formatEvent(baseEvent, { colorize: true });
|
|
|
|
// ANSI color codes should be present
|
|
expect(formatted).toContain('\x1b[');
|
|
});
|
|
|
|
it('should use correct colors for each level', () => {
|
|
const levels: Array<{ level: LogLevel; color: string }> = [
|
|
{ level: 'debug', color: '\x1b[36m' }, // cyan
|
|
{ level: 'info', color: '\x1b[32m' }, // green
|
|
{ level: 'warn', color: '\x1b[33m' }, // yellow
|
|
{ level: 'error', color: '\x1b[31m' }, // red
|
|
];
|
|
|
|
for (const { level, color } of levels) {
|
|
const event: LogEvent = { ...baseEvent, level };
|
|
const formatted = formatEvent(event, { colorize: true });
|
|
|
|
expect(formatted).toContain(color);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseLogLine - edge cases with optional fields', () => {
|
|
it('should reject log when tool is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
tool: 123, // Should be string
|
|
});
|
|
|
|
// Parser should accept this since tool is optional and validated
|
|
const result = parseLogLine(line);
|
|
|
|
// Optional fields with wrong types should be ignored
|
|
expect(result).not.toBeNull();
|
|
expect(result?.tool).toBeUndefined();
|
|
});
|
|
|
|
it('should reject log when path is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
path: { file: 'test.ts' }, // Should be string
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.path).toBeUndefined();
|
|
});
|
|
|
|
it('should reject log when bead is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
bead: ['bd-123'], // Should be string
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.bead).toBeUndefined();
|
|
});
|
|
|
|
it('should reject log when duration_ms is not a number', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
duration_ms: '5000', // Should be number
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.duration_ms).toBeUndefined();
|
|
});
|
|
|
|
it('should reject log when error is not a string', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
error: { message: 'error' }, // Should be string
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.error).toBeUndefined();
|
|
});
|
|
|
|
it('should accept zero duration_ms', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
duration_ms: 0,
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.duration_ms).toBe(0);
|
|
});
|
|
|
|
it('should accept negative duration_ms', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
duration_ms: -100,
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.duration_ms).toBe(-100);
|
|
});
|
|
|
|
it('should accept empty strings for optional fields', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
tool: '',
|
|
path: '',
|
|
bead: '',
|
|
error: '',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.tool).toBe('');
|
|
expect(result?.path).toBe('');
|
|
expect(result?.bead).toBe('');
|
|
expect(result?.error).toBe('');
|
|
});
|
|
|
|
it('should handle unicode and special characters in message', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: '🚀 Testing with émojis and spëcial çharacters: 你好世界',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.msg).toBe('🚀 Testing with émojis and spëcial çharacters: 你好世界');
|
|
});
|
|
|
|
it('should handle very long message strings', () => {
|
|
const longMsg = 'A'.repeat(10000);
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: longMsg,
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.msg).toBe(longMsg);
|
|
expect(result?.msg.length).toBe(10000);
|
|
});
|
|
|
|
it('should handle very large timestamps', () => {
|
|
const line = JSON.stringify({
|
|
ts: 9999999999999, // Year 2286
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.ts).toBe(9999999999999);
|
|
});
|
|
|
|
it('should handle negative timestamps', () => {
|
|
const line = JSON.stringify({
|
|
ts: -1000, // Before Unix epoch
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.ts).toBe(-1000);
|
|
});
|
|
|
|
it('should preserve custom fields with various types', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
stringField: 'value',
|
|
numberField: 42,
|
|
booleanField: true,
|
|
nullField: null,
|
|
arrayField: [1, 2, 3],
|
|
objectField: { nested: 'value' },
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).toMatchObject({
|
|
stringField: 'value',
|
|
numberField: 42,
|
|
booleanField: true,
|
|
nullField: null,
|
|
arrayField: [1, 2, 3],
|
|
objectField: { nested: 'value' },
|
|
});
|
|
});
|
|
|
|
it('should handle deeply nested custom objects', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
metadata: {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
value: 'deep',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result?.metadata).toEqual({
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
value: 'deep',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseLogLines - performance and batch processing', () => {
|
|
it('should handle large batches efficiently (1000 lines)', () => {
|
|
const lines = Array.from({ length: 1000 }, (_, i) =>
|
|
JSON.stringify({
|
|
ts: 1709337600000 + i,
|
|
worker: `w-${i % 10}`,
|
|
level: ['debug', 'info', 'warn', 'error'][i % 4] as LogLevel,
|
|
msg: `Message ${i}`,
|
|
})
|
|
).join('\n');
|
|
|
|
const start = Date.now();
|
|
const results = parseLogLines(lines);
|
|
const duration = Date.now() - start;
|
|
|
|
expect(results).toHaveLength(1000);
|
|
expect(duration).toBeLessThan(1000); // Should parse 1000 lines in under 1s
|
|
|
|
// Verify first and last entries
|
|
expect(results[0].msg).toBe('Message 0');
|
|
expect(results[999].msg).toBe('Message 999');
|
|
});
|
|
|
|
it('should handle large batches with some invalid lines (10000 lines)', () => {
|
|
const lines = Array.from({ length: 10000 }, (_, i) => {
|
|
// Every 10th line is invalid
|
|
if (i % 10 === 0) {
|
|
return 'invalid json line';
|
|
}
|
|
return JSON.stringify({
|
|
ts: 1709337600000 + i,
|
|
worker: `w-${i % 10}`,
|
|
level: ['debug', 'info', 'warn', 'error'][i % 4] as LogLevel,
|
|
msg: `Message ${i}`,
|
|
});
|
|
}).join('\n');
|
|
|
|
const start = Date.now();
|
|
const results = parseLogLines(lines);
|
|
const duration = Date.now() - start;
|
|
|
|
// Should have 9000 valid lines (10000 - 1000 invalid)
|
|
expect(results).toHaveLength(9000);
|
|
expect(duration).toBeLessThan(5000); // Should parse 10000 lines in under 5s
|
|
});
|
|
|
|
it('should handle batches with mixed valid and malformed JSON', () => {
|
|
const lines = [
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'Valid 1' }),
|
|
'{"ts": 2, "worker": "w2", "level": "info", "msg": "Valid 2"', // Unclosed
|
|
JSON.stringify({ ts: 3, worker: 'w3', level: 'info', msg: 'Valid 3' }),
|
|
'{"ts": 4}', // Missing required fields
|
|
JSON.stringify({ ts: 5, worker: 'w5', level: 'info', msg: 'Valid 5' }),
|
|
'plain text not json',
|
|
JSON.stringify({ ts: 6, worker: 'w6', level: 'info', msg: 'Valid 6' }),
|
|
].join('\n');
|
|
|
|
const results = parseLogLines(lines);
|
|
|
|
expect(results).toHaveLength(4); // Only 4 valid lines
|
|
expect(results.map(r => r.msg)).toEqual([
|
|
'Valid 1',
|
|
'Valid 3',
|
|
'Valid 5',
|
|
'Valid 6',
|
|
]);
|
|
});
|
|
|
|
it('should maintain correct order with large batches', () => {
|
|
const lines = Array.from({ length: 5000 }, (_, i) =>
|
|
JSON.stringify({
|
|
ts: 1709337600000 + i,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: `Message ${i}`,
|
|
})
|
|
).join('\n');
|
|
|
|
const results = parseLogLines(lines);
|
|
|
|
// Verify order is maintained
|
|
for (let i = 0; i < results.length; i++) {
|
|
expect(results[i].msg).toBe(`Message ${i}`);
|
|
expect(results[i].ts).toBe(1709337600000 + i);
|
|
}
|
|
});
|
|
|
|
it('should handle batches with very long lines', () => {
|
|
const longMsg = 'A'.repeat(100000); // 100KB message
|
|
const lines = Array.from({ length: 100 }, (_, i) =>
|
|
JSON.stringify({
|
|
ts: 1709337600000 + i,
|
|
worker: `w-${i}`,
|
|
level: 'info',
|
|
msg: `${i}: ${longMsg}`,
|
|
})
|
|
).join('\n');
|
|
|
|
const start = Date.now();
|
|
const results = parseLogLines(lines);
|
|
const duration = Date.now() - start;
|
|
|
|
expect(results).toHaveLength(100);
|
|
expect(duration).toBeLessThan(5000); // Should handle large lines reasonably
|
|
expect(results[0].msg).toContain('0:');
|
|
expect(results[0].msg.length).toBeGreaterThan(100000);
|
|
});
|
|
|
|
it('should handle empty lines interspersed with valid lines', () => {
|
|
const lines = Array.from({ length: 1000 }, (_, i) => {
|
|
// Every 3rd line is empty or whitespace
|
|
if (i % 3 === 0) {
|
|
return i % 6 === 0 ? '' : ' ';
|
|
}
|
|
return JSON.stringify({
|
|
ts: 1709337600000 + i,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: `Message ${i}`,
|
|
});
|
|
}).join('\n');
|
|
|
|
const results = parseLogLines(lines);
|
|
|
|
// Should have ~667 valid lines (1000 - ~333 empty)
|
|
expect(results.length).toBeGreaterThan(600);
|
|
expect(results.length).toBeLessThan(700);
|
|
});
|
|
});
|
|
|
|
describe('formatEvent - additional edge cases', () => {
|
|
const baseEvent: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test message',
|
|
};
|
|
|
|
it('should format zero duration', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 0 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('0ms');
|
|
});
|
|
|
|
it('should format very small duration', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 1 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('1ms');
|
|
});
|
|
|
|
it('should format duration at threshold (999ms)', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 999 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('999ms');
|
|
});
|
|
|
|
it('should format duration at threshold (1000ms)', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 1000 };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('1.0s');
|
|
});
|
|
|
|
it('should format very long duration (hours)', () => {
|
|
const event: LogEvent = { ...baseEvent, duration_ms: 7200000 }; // 2 hours
|
|
const formatted = formatEvent(event);
|
|
|
|
// Should be formatted as minutes
|
|
expect(formatted).toContain('120m 0s');
|
|
});
|
|
|
|
it('should not include empty optional string fields', () => {
|
|
const event: LogEvent = {
|
|
...baseEvent,
|
|
tool: '',
|
|
path: '',
|
|
bead: '',
|
|
error: '',
|
|
};
|
|
const formatted = formatEvent(event);
|
|
|
|
// Empty strings are falsy, so they should not appear in output
|
|
expect(formatted).not.toContain('[]');
|
|
expect(formatted).not.toContain('bead:');
|
|
expect(formatted).not.toContain('ERROR:');
|
|
expect(formatted).toContain('Test message'); // Main message still present
|
|
});
|
|
|
|
it('should format event with all optional fields', () => {
|
|
const event: LogEvent = {
|
|
...baseEvent,
|
|
tool: 'Read',
|
|
path: '/src/test.ts',
|
|
bead: 'bd-xyz',
|
|
duration_ms: 1500,
|
|
error: 'File not found',
|
|
};
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('[Read]');
|
|
expect(formatted).toContain('/src/test.ts');
|
|
expect(formatted).toContain('bead:bd-xyz');
|
|
expect(formatted).toContain('1.5s');
|
|
expect(formatted).toContain('ERROR: File not found');
|
|
});
|
|
|
|
it('should handle unicode in worker ID', () => {
|
|
const event: LogEvent = { ...baseEvent, worker: 'w-🚀-test' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('w-🚀-test');
|
|
});
|
|
|
|
it('should handle very long worker ID', () => {
|
|
const event: LogEvent = { ...baseEvent, worker: 'w-very-long-worker-id-12345' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('w-very-long-worker-id-12345');
|
|
});
|
|
|
|
it('should handle newlines in message', () => {
|
|
const event: LogEvent = { ...baseEvent, msg: 'Line 1\nLine 2\nLine 3' };
|
|
const formatted = formatEvent(event);
|
|
|
|
expect(formatted).toContain('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
it('should format with both showWorker and showLevel false', () => {
|
|
const formatted = formatEvent(baseEvent, { showWorker: false, showLevel: false });
|
|
|
|
expect(formatted).not.toContain('w-test');
|
|
expect(formatted).not.toContain('INFO');
|
|
expect(formatted).toContain('Test message');
|
|
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/); // Timestamp still present
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Conversation Event Parsing Tests
|
|
// ============================================
|
|
|
|
describe('isConversationEvent', () => {
|
|
it('should return true for events with conversation_role field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
conversation_role: 'user',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with conversation_type field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
conversation_type: 'prompt',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with prompt field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
prompt: 'What is the weather?',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with response field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
response: 'The weather is sunny.',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with thinking field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
thinking: 'Let me think about this...',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with tool and tool_args', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Tool call',
|
|
tool: 'Read',
|
|
tool_args: { file_path: '/src/main.ts' },
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return true for events with content field', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
content: 'Some content',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
|
|
it('should return false for regular log events', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Starting task',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(false);
|
|
});
|
|
|
|
it('should return true for message patterns containing "user prompt"', () => {
|
|
const event: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Received user prompt',
|
|
};
|
|
expect(isConversationEvent(event)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parseConversationEvent', () => {
|
|
const baseLogEvent: LogEvent = {
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
};
|
|
|
|
describe('prompt events', () => {
|
|
it('should parse a prompt event with prompt field', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
prompt: 'What is the weather today?',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('prompt');
|
|
expect(result?.role).toBe('user');
|
|
expect((result as any)?.content).toBe('What is the weather today?');
|
|
});
|
|
|
|
it('should parse a prompt event with conversation_role=user', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_role: 'user',
|
|
content: 'Hello world',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 1);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('prompt');
|
|
expect(result?.role).toBe('user');
|
|
expect((result as any)?.content).toBe('Hello world');
|
|
expect(result?.sequence).toBe(1);
|
|
});
|
|
|
|
it('should include bead and tokens in prompt event', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
prompt: 'Test prompt',
|
|
bead: 'bd-abc',
|
|
tokens: 100,
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result?.bead).toBe('bd-abc');
|
|
expect(result?.tokens).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('response events', () => {
|
|
it('should parse a response event with response field', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
response: 'The weather is sunny today.',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('response');
|
|
expect(result?.role).toBe('assistant');
|
|
expect((result as any)?.content).toBe('The weather is sunny today.');
|
|
});
|
|
|
|
it('should parse response with model and stop reason', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
response: 'Response text',
|
|
model: 'claude-3-opus',
|
|
stop_reason: 'end_turn',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.model).toBe('claude-3-opus');
|
|
expect((result as any)?.stopReason).toBe('end_turn');
|
|
});
|
|
|
|
it('should mark truncated content', () => {
|
|
const longContent = 'A'.repeat(15000);
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
response: longContent,
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0, { maxContentLength: 10000 });
|
|
|
|
expect((result as any)?.isTruncated).toBe(true);
|
|
expect((result as any)?.content.length).toBeLessThan(longContent.length);
|
|
});
|
|
});
|
|
|
|
describe('thinking events', () => {
|
|
it('should parse a thinking event with thinking field', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
thinking: 'Let me analyze this problem...',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('thinking');
|
|
expect(result?.role).toBe('assistant');
|
|
expect((result as any)?.content).toBe('Let me analyze this problem...');
|
|
});
|
|
|
|
it('should include thinking duration', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
thinking: 'Thinking...',
|
|
thinking_duration_ms: 5000,
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.durationMs).toBe(5000);
|
|
});
|
|
|
|
it('should parse thinking from message pattern', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
msg: 'Processing thinking block',
|
|
content: 'My thoughts...',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result?.type).toBe('thinking');
|
|
});
|
|
});
|
|
|
|
describe('tool call events', () => {
|
|
it('should parse a tool call event', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Read',
|
|
tool_args: { file_path: '/src/main.ts' },
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('tool_call');
|
|
expect(result?.role).toBe('assistant');
|
|
expect((result as any)?.tool).toBe('Read');
|
|
expect((result as any)?.args).toEqual({ file_path: '/src/main.ts' });
|
|
});
|
|
|
|
it('should generate summary for tool call', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Read',
|
|
tool_args: { file_path: '/src/test.ts' },
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.summary).toBe('Read /src/test.ts');
|
|
});
|
|
|
|
it('should generate summary for Bash tool', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Bash',
|
|
tool_args: { command: 'npm test -- --coverage' },
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.summary).toContain('Run:');
|
|
expect((result as any)?.summary).toContain('npm test');
|
|
});
|
|
|
|
it('should include tool call ID', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Read',
|
|
tool_args: {},
|
|
tool_call_id: 'call-123',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.toolCallId).toBe('call-123');
|
|
});
|
|
|
|
it('should normalize tool_args from various field names', () => {
|
|
const event1: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Write',
|
|
tool_input: { file_path: '/a.ts' },
|
|
};
|
|
const event2: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Write',
|
|
args: { file_path: '/b.ts' },
|
|
};
|
|
|
|
const result1 = parseConversationEvent(event1, 0);
|
|
const result2 = parseConversationEvent(event2, 0);
|
|
|
|
expect((result1 as any)?.args).toEqual({ file_path: '/a.ts' });
|
|
expect((result2 as any)?.args).toEqual({ file_path: '/b.ts' });
|
|
});
|
|
});
|
|
|
|
describe('tool result events', () => {
|
|
it('should parse a successful tool result', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Read',
|
|
result: 'File contents here',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('tool_result');
|
|
expect(result?.role).toBe('tool');
|
|
expect((result as any)?.tool).toBe('Read');
|
|
expect((result as any)?.content).toBe('File contents here');
|
|
expect((result as any)?.success).toBe(true);
|
|
});
|
|
|
|
it('should parse a failed tool result', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Read',
|
|
result: 'Error reading file',
|
|
error: 'File not found',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.success).toBe(false);
|
|
expect((result as any)?.error).toBe('File not found');
|
|
});
|
|
|
|
it('should include duration in tool result', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool: 'Bash',
|
|
result: 'Command output',
|
|
duration_ms: 1500,
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
|
|
expect((result as any)?.durationMs).toBe(1500);
|
|
});
|
|
});
|
|
|
|
describe('explicit conversation_type', () => {
|
|
it('should parse by conversation_type=prompt', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_type: 'prompt',
|
|
content: 'User prompt',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result?.type).toBe('prompt');
|
|
});
|
|
|
|
it('should parse by conversation_type=response', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_type: 'response',
|
|
content: 'Assistant response',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result?.type).toBe('response');
|
|
});
|
|
|
|
it('should parse by conversation_type=thinking', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_type: 'thinking',
|
|
content: 'Thinking...',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result?.type).toBe('thinking');
|
|
});
|
|
|
|
it('should parse by conversation_type=tool_call', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_type: 'tool_call',
|
|
tool: 'Read',
|
|
tool_args: {},
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result?.type).toBe('tool_call');
|
|
});
|
|
|
|
it('should parse by conversation_type=tool_result', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
conversation_type: 'tool_result',
|
|
tool: 'Read',
|
|
result: 'content',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result?.type).toBe('tool_result');
|
|
});
|
|
});
|
|
|
|
describe('return null cases', () => {
|
|
it('should return null for non-conversation events', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
msg: 'Starting task',
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for tool call without tool name', () => {
|
|
const event: LogEvent = {
|
|
...baseLogEvent,
|
|
tool_args: { file_path: '/test.ts' },
|
|
};
|
|
|
|
const result = parseConversationEvent(event, 0);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseConversationEvents', () => {
|
|
it('should parse multiple conversation events', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: 1, worker: 'w1', level: 'info', msg: 'Test', prompt: 'Hello' },
|
|
{ ts: 2, worker: 'w1', level: 'info', msg: 'Test', response: 'Hi there' },
|
|
{ ts: 3, worker: 'w1', level: 'info', msg: 'Test', tool: 'Read', tool_args: { file_path: '/a.ts' } },
|
|
];
|
|
|
|
const results = parseConversationEvents(events);
|
|
|
|
expect(results).toHaveLength(3);
|
|
expect(results[0].type).toBe('prompt');
|
|
expect(results[1].type).toBe('response');
|
|
expect(results[2].type).toBe('tool_call');
|
|
});
|
|
|
|
it('should filter out thinking events when disabled', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: 1, worker: 'w1', level: 'info', msg: 'Test', prompt: 'Hello' },
|
|
{ ts: 2, worker: 'w1', level: 'info', msg: 'Test', thinking: 'Let me think...' },
|
|
{ ts: 3, worker: 'w1', level: 'info', msg: 'Test', response: 'Response' },
|
|
];
|
|
|
|
const results = parseConversationEvents(events, { includeThinking: false });
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].type).toBe('prompt');
|
|
expect(results[1].type).toBe('response');
|
|
});
|
|
|
|
it('should filter out tool results when disabled', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: 1, worker: 'w1', level: 'info', msg: 'Test', tool: 'Read', tool_args: {} },
|
|
{ ts: 2, worker: 'w1', level: 'info', msg: 'Test', tool: 'Read', result: 'content' },
|
|
];
|
|
|
|
const results = parseConversationEvents(events, { includeToolResults: false });
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].type).toBe('tool_call');
|
|
});
|
|
|
|
it('should assign sequential sequence numbers', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: 1, worker: 'w1', level: 'info', msg: 'Test', prompt: 'A' },
|
|
{ ts: 2, worker: 'w1', level: 'info', msg: 'Test', prompt: 'B' },
|
|
{ ts: 3, worker: 'w1', level: 'info', msg: 'Test', prompt: 'C' },
|
|
];
|
|
|
|
const results = parseConversationEvents(events);
|
|
|
|
expect(results[0].sequence).toBe(0);
|
|
expect(results[1].sequence).toBe(1);
|
|
expect(results[2].sequence).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('parseConversationLine', () => {
|
|
it('should parse a conversation event from a log line', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Test',
|
|
prompt: 'What is this?',
|
|
});
|
|
|
|
const result = parseConversationLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe('prompt');
|
|
});
|
|
|
|
it('should return null for invalid JSON', () => {
|
|
const result = parseConversationLine('not json');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-conversation log line', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-test',
|
|
level: 'info',
|
|
msg: 'Starting task',
|
|
});
|
|
|
|
const result = parseConversationLine(line);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseConversationContent', () => {
|
|
it('should parse conversation events from multi-line content', () => {
|
|
const content = [
|
|
JSON.stringify({ ts: 1, worker: 'w1', level: 'info', msg: 'Test', prompt: 'Q1' }),
|
|
JSON.stringify({ ts: 2, worker: 'w1', level: 'info', msg: 'Test', response: 'A1' }),
|
|
JSON.stringify({ ts: 3, worker: 'w1', level: 'info', msg: 'Test', prompt: 'Q2' }),
|
|
].join('\n');
|
|
|
|
const results = parseConversationContent(content);
|
|
|
|
expect(results).toHaveLength(3);
|
|
});
|
|
|
|
it('should handle empty content', () => {
|
|
const results = parseConversationContent('');
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('formatConversationEvent', () => {
|
|
const baseTime = 1709337600000;
|
|
|
|
it('should format a prompt event', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-1',
|
|
type: 'prompt',
|
|
role: 'user',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 0,
|
|
content: 'Hello world',
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('[user]');
|
|
expect(formatted).toContain('Hello world');
|
|
});
|
|
|
|
it('should format a response event', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-2',
|
|
type: 'response',
|
|
role: 'assistant',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 1,
|
|
content: 'Response text',
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('[assistant]');
|
|
expect(formatted).toContain('Response text');
|
|
});
|
|
|
|
it('should format a thinking event', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-3',
|
|
type: 'thinking',
|
|
role: 'assistant',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 2,
|
|
content: 'My thoughts...',
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('[assistant]');
|
|
expect(formatted).toContain('<thinking>');
|
|
expect(formatted).toContain('My thoughts...');
|
|
});
|
|
|
|
it('should format a tool call event', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-4',
|
|
type: 'tool_call',
|
|
role: 'assistant',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 3,
|
|
tool: 'Read',
|
|
args: { file_path: '/test.ts' },
|
|
summary: 'Read /test.ts',
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('Tool:');
|
|
expect(formatted).toContain('Read /test.ts');
|
|
});
|
|
|
|
it('should format a successful tool result', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-5',
|
|
type: 'tool_result',
|
|
role: 'tool',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 4,
|
|
tool: 'Read',
|
|
content: 'file contents',
|
|
success: true,
|
|
durationMs: 500,
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('Tool result:');
|
|
expect(formatted).toContain('Read');
|
|
expect(formatted).toContain('✓');
|
|
expect(formatted).toContain('500ms');
|
|
});
|
|
|
|
it('should format a failed tool result', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-6',
|
|
type: 'tool_result',
|
|
role: 'tool',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 5,
|
|
tool: 'Read',
|
|
content: '',
|
|
success: false,
|
|
error: 'File not found',
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('✗');
|
|
});
|
|
|
|
it('should indicate truncated content', () => {
|
|
const event: ConversationEvent = {
|
|
id: 'ce-7',
|
|
type: 'response',
|
|
role: 'assistant',
|
|
ts: baseTime,
|
|
worker: 'w-test',
|
|
sequence: 6,
|
|
content: 'Long text...',
|
|
isTruncated: true,
|
|
};
|
|
|
|
const formatted = formatConversationEvent(event);
|
|
|
|
expect(formatted).toContain('[truncated]');
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// NEEDLE Log Format Tests
|
|
// ============================================
|
|
|
|
/**
|
|
* Tests for NEEDLE structured log format parsing.
|
|
*
|
|
* NEEDLE format structure:
|
|
* {
|
|
* ts: ISO 8601 string,
|
|
* event: string (e.g., "worker.started", "bead.claimed"),
|
|
* session: string,
|
|
* worker: { runner, provider, model, identifier },
|
|
* data: { ...event-specific payload }
|
|
* }
|
|
*
|
|
* Sample log lines from ~/.needle/logs/
|
|
*/
|
|
describe('parseLogLine - NEEDLE format', () => {
|
|
describe('worker.started event', () => {
|
|
it('should parse worker.started event with minimal fields', () => {
|
|
// Sample from ~/.needle/logs/needle-claude-anthropic-sonnet-test12.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'needle-claude-anthropic-sonnet-test12',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'anthropic',
|
|
model: 'sonnet',
|
|
identifier: 'test12'
|
|
},
|
|
data: {
|
|
pid: 1929276,
|
|
workspace: '/home/coder/NEEDLE',
|
|
agent: 'claude-anthropic-sonnet'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.ts).toBe(new Date('2026-03-04T16:17:34.008Z').getTime());
|
|
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.msg).toBe('worker.started');
|
|
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
|
expect(result?.provider).toBe('anthropic');
|
|
expect(result?.model).toBe('sonnet');
|
|
});
|
|
|
|
it('should parse worker.started event with full data', () => {
|
|
// Sample from ~/.needle/logs/forge-glm-test.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:31:30.245Z',
|
|
event: 'worker.started',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
workspace: '/home/coder/forge',
|
|
agent: 'claude-code-glm-4.7',
|
|
session: 'forge-glm-test',
|
|
timestamp: '2026-03-04T19:31:30Z'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.worker).toBe('claude-code-glm-4.7-test');
|
|
expect(result?.provider).toBe('code');
|
|
expect(result?.model).toBe('glm-4.7');
|
|
expect(result?.session).toBe('forge-glm-test');
|
|
// Additional data fields should be preserved
|
|
expect(result?.workspace).toBe('/home/coder/forge');
|
|
expect(result?.agent).toBe('claude-code-glm-4.7');
|
|
});
|
|
});
|
|
|
|
describe('bead.claimed event', () => {
|
|
it('should parse bead.claimed event with bead_id', () => {
|
|
// Sample from ~/.needle/logs/forge-glm-test.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:31:34.851Z',
|
|
event: 'bead.claimed',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id: 'bd-2ok0',
|
|
actor: 'forge-glm-test',
|
|
attempt: 1,
|
|
workspace: '/home/coder/forge'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('bead.claimed');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.bead).toBe('bd-2ok0');
|
|
expect(result?.worker).toBe('claude-code-glm-4.7-test');
|
|
expect(result?.attempt).toBe(1);
|
|
expect(result?.actor).toBe('forge-glm-test');
|
|
});
|
|
|
|
it('should parse bead.claimed event with title', () => {
|
|
// Sample from ~/.needle/logs/forge-glm-test.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:31:34.978Z',
|
|
event: 'bead.claimed',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id: 'bd-2ok0',
|
|
workspace: '/home/coder/forge',
|
|
agent: 'claude-code-glm-4.7',
|
|
title: 'Add model alias mapping for opencode'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.bead).toBe('bd-2ok0');
|
|
expect(result?.title).toBe('Add model alias mapping for opencode');
|
|
});
|
|
});
|
|
|
|
describe('bead.completed event', () => {
|
|
it('should parse bead.completed event with duration', () => {
|
|
// Sample from ~/.needle/logs/forge-glm-test.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:37:19.590Z',
|
|
event: 'bead.completed',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id: 'bd-2ok0',
|
|
duration_ms: 28854,
|
|
output_file: '/tmp/needle-dispatch-bd-2ok0-FHwgcG7A.log'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('bead.completed');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.bead).toBe('bd-2ok0');
|
|
expect(result?.duration_ms).toBe(28854);
|
|
expect(result?.output_file).toBe('/tmp/needle-dispatch-bd-2ok0-FHwgcG7A.log');
|
|
});
|
|
});
|
|
|
|
describe('bead.claim_retry event', () => {
|
|
it('should parse bead.claim_retry event with warn level', () => {
|
|
// NEEDLE emits bead.claim_retry with explicit level: "warn"
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:37:22.192Z',
|
|
event: 'bead.claim_retry',
|
|
level: 'warn',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id: 'bd-e6jq',
|
|
attempt: 1,
|
|
max_retries: 5,
|
|
actor: 'forge-glm-test'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('bead.claim_retry');
|
|
expect(result?.level).toBe('warn'); // NEEDLE emits this with explicit level: "warn"
|
|
expect(result?.bead).toBe('bd-e6jq');
|
|
expect(result?.attempt).toBe(1);
|
|
expect(result?.max_retries).toBe(5);
|
|
});
|
|
|
|
it('should parse multiple claim_retry attempts', () => {
|
|
const attempts = [
|
|
{ attempt: 2, bead_id: 'bd-2ee5' },
|
|
{ attempt: 3, bead_id: 'bd-e6jq' },
|
|
{ attempt: 4, bead_id: 'bd-e6jq' },
|
|
{ attempt: 5, bead_id: 'bd-e6jq' }
|
|
];
|
|
|
|
for (const { attempt, bead_id } of attempts) {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:37:22.536Z',
|
|
event: 'bead.claim_retry',
|
|
level: 'warn',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id,
|
|
attempt,
|
|
max_retries: 5,
|
|
actor: 'forge-glm-test'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.level).toBe('warn');
|
|
expect(result?.attempt).toBe(attempt);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('bead.claim_exhausted event', () => {
|
|
it('should parse bead.claim_exhausted event with error level', () => {
|
|
// NEEDLE emits bead.claim_exhausted with explicit level: "error"
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:37:23.647Z',
|
|
event: 'bead.claim_exhausted',
|
|
level: 'error',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
max_retries: 5,
|
|
actor: 'forge-glm-test',
|
|
workspace: '/home/coder/forge'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('bead.claim_exhausted');
|
|
expect(result?.level).toBe('error'); // NEEDLE emits this with explicit level: "error"
|
|
expect(result?.max_retries).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('heartbeat.emitted event', () => {
|
|
it('should parse heartbeat.emitted event', () => {
|
|
// Constructed based on NEEDLE format pattern
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'heartbeat.emitted',
|
|
session: 'needle-claude-anthropic-sonnet-test12',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'anthropic',
|
|
model: 'sonnet',
|
|
identifier: 'test12'
|
|
},
|
|
data: {
|
|
uptime_seconds: 3600,
|
|
beads_completed: 5,
|
|
last_bead_id: 'bd-abc123'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('heartbeat.emitted');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
|
|
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
|
expect(result?.uptime_seconds).toBe(3600);
|
|
expect(result?.beads_completed).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('worker.idle event', () => {
|
|
it('should parse worker.idle event', () => {
|
|
// Sample from ~/.needle/logs/needle-claude-anthropic-sonnet-test12.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:36.243Z',
|
|
event: 'worker.idle',
|
|
session: 'needle-claude-anthropic-sonnet-test12',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'anthropic',
|
|
model: 'sonnet',
|
|
identifier: 'test12'
|
|
},
|
|
data: {
|
|
consecutive_empty: 1,
|
|
idle_seconds: 0,
|
|
workspace: '/home/coder/NEEDLE',
|
|
agent: 'claude-anthropic-sonnet'
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('worker.idle');
|
|
expect(result?.level).toBe('info');
|
|
expect(result?.consecutive_empty).toBe(1);
|
|
expect(result?.idle_seconds).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('effort.recorded event', () => {
|
|
it('should parse effort.recorded event with duration', () => {
|
|
// Sample from ~/.needle/logs/forge-glm-test.log
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T19:37:19.616Z',
|
|
event: 'effort.recorded',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test'
|
|
},
|
|
data: {
|
|
bead_id: 'bd-2ok0',
|
|
duration_ms: 28854
|
|
}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.msg).toBe('effort.recorded');
|
|
expect(result?.bead).toBe('bd-2ok0');
|
|
expect(result?.duration_ms).toBe(28854);
|
|
});
|
|
});
|
|
|
|
describe('level inference from event names', () => {
|
|
// inferLogLevel is the fallback used when no explicit level field is present (legacy logs).
|
|
// NEEDLE always includes level explicitly; these tests verify the inference rules match
|
|
// NEEDLE's _needle_telemetry_infer_level: error.* -> error, *.failed/*.retry -> warn,
|
|
// debug.* -> debug, else -> info.
|
|
|
|
it('should infer error level for error.* prefix events', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'error.agent_crash',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('error');
|
|
});
|
|
|
|
it('should infer warn level for *.failed suffix events', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'bead.failed',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('warn');
|
|
});
|
|
|
|
it('should infer warn level for *.retry suffix events', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'claim.retry',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('warn');
|
|
});
|
|
|
|
it('should infer debug level for debug.* prefix events', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'debug.probe',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('debug');
|
|
});
|
|
|
|
it('should default to info level for unknown events', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'custom.event',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('info');
|
|
});
|
|
|
|
it('should use explicit level field when present rather than inferring', () => {
|
|
// bead.claim_retry would infer info (ends with _retry not .retry) but NEEDLE
|
|
// always emits it with explicit level: "warn"
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'bead.claim_retry',
|
|
level: 'warn',
|
|
session: 'test-session',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.level).toBe('warn');
|
|
});
|
|
});
|
|
|
|
describe('timestamp conversion', () => {
|
|
it('should convert ISO 8601 timestamp to Unix milliseconds', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
|
|
// Verify the timestamp is correctly converted (2026-03-04T16:17:34.008Z)
|
|
expect(result?.ts).toBe(1772641054008);
|
|
});
|
|
|
|
it('should handle timestamps with different timezone offsets', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34+00:00',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result).not.toBeNull();
|
|
expect(typeof result?.ts).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('worker identifier flattening', () => {
|
|
it('should flatten worker object to runner-provider-model-identifier format', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: { runner: 'claude', provider: 'anthropic', model: 'opus', identifier: 'prod' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.worker).toBe('claude-anthropic-opus-prod');
|
|
});
|
|
|
|
it('should preserve provider and model as separate fields', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: { runner: 'claude', provider: 'anthropic', model: 'opus-4', identifier: 'prod' },
|
|
data: {}
|
|
});
|
|
|
|
const result = parseLogLine(line);
|
|
expect(result?.provider).toBe('anthropic');
|
|
expect(result?.model).toBe('opus-4');
|
|
});
|
|
});
|
|
|
|
describe('mixed NEEDLE and legacy format', () => {
|
|
it('should parse NEEDLE format when mixed with legacy format', () => {
|
|
const needleLine = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'test',
|
|
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
|
data: {}
|
|
});
|
|
|
|
const legacyLine = JSON.stringify({
|
|
ts: 1709569054008,
|
|
worker: 'w-legacy',
|
|
level: 'info',
|
|
msg: 'Legacy message'
|
|
});
|
|
|
|
const needleResult = parseLogLine(needleLine);
|
|
const legacyResult = parseLogLine(legacyLine);
|
|
|
|
expect(needleResult?.worker).toBe('claude-code-sonnet-test');
|
|
expect(needleResult?.msg).toBe('worker.started');
|
|
|
|
expect(legacyResult?.worker).toBe('w-legacy');
|
|
expect(legacyResult?.msg).toBe('Legacy message');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// parseNeedleEvent Tests (bd-6q2)
|
|
// ============================================
|
|
|
|
describe('parseNeedleEvent', () => {
|
|
describe('canonical format', () => {
|
|
it('should parse a canonical NeedleEvent preserving all fields', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.353624Z',
|
|
event_type: 'worker.started',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 0,
|
|
data: { version: '0.1.0', worker_name: 'echo' },
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.timestamp).toBe('2026-04-08T20:10:43.353624Z');
|
|
expect(result!.event_type).toBe('worker.started');
|
|
expect(result!.worker_id).toBe('echo');
|
|
expect(result!.session_id).toBe('66745068');
|
|
expect(result!.sequence).toBe(0);
|
|
expect(result!.data).toEqual({ version: '0.1.0', worker_name: 'echo' });
|
|
});
|
|
|
|
it('should preserve bead_id when present', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:11:00.000Z',
|
|
event_type: 'bead.completed',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 5,
|
|
bead_id: 'bd-abc123',
|
|
data: { duration_ms: 15000 },
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.bead_id).toBe('bd-abc123');
|
|
});
|
|
|
|
it('should return undefined bead_id when absent', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'worker.idle',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 1,
|
|
data: {},
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.bead_id).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve deeply nested data fields', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'bead.claimed',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 2,
|
|
data: {
|
|
nested: { deep: { value: 42, arr: [1, 2, 3] } },
|
|
metadata: { tags: ['a', 'b'] },
|
|
},
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.data.nested).toEqual({ deep: { value: 42, arr: [1, 2, 3] } });
|
|
expect(result!.data.metadata).toEqual({ tags: ['a', 'b'] });
|
|
});
|
|
|
|
it('should default data to empty object when missing', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'worker.started',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 0,
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.data).toEqual({});
|
|
});
|
|
|
|
it('should accept schema_version matching current version', () => {
|
|
const line = JSON.stringify({
|
|
schema_version: NEEDLE_EVENT_SCHEMA_VERSION,
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'worker.started',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 0,
|
|
data: {},
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.event_type).toBe('worker.started');
|
|
});
|
|
|
|
it('should throw on schema_version mismatch', () => {
|
|
const line = JSON.stringify({
|
|
schema_version: 999,
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'worker.started',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 0,
|
|
data: {},
|
|
});
|
|
|
|
expect(() => parseNeedleEvent(line)).toThrow(/schema mismatch/);
|
|
});
|
|
});
|
|
|
|
describe('session_id, sequence, and data round-trip', () => {
|
|
it('should preserve session_id through parseNeedleEvent → needleEventToLogEvent round-trip', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.353624Z',
|
|
event_type: 'bead.claimed',
|
|
worker_id: 'echo',
|
|
session_id: '66745068',
|
|
sequence: 3,
|
|
bead_id: 'bd-xyz',
|
|
data: { workspace: '/home/coder/NEEDLE' },
|
|
});
|
|
|
|
const ne = parseNeedleEvent(line);
|
|
expect(ne).not.toBeNull();
|
|
expect(ne!.session_id).toBe('66745068');
|
|
expect(ne!.sequence).toBe(3);
|
|
|
|
// Round-trip through LogEvent adapter
|
|
const le = parseLogLine(line);
|
|
expect(le).not.toBeNull();
|
|
expect(le!.session).toBe('66745068');
|
|
expect(le!.bead).toBe('bd-xyz');
|
|
});
|
|
|
|
it('should preserve arbitrary data payload keys', () => {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: 'effort.recorded',
|
|
worker_id: 'echo',
|
|
session_id: 'abc123',
|
|
sequence: 10,
|
|
bead_id: 'bd-test',
|
|
data: {
|
|
duration_ms: 28854,
|
|
cost_usd: 0.037,
|
|
model: 'glm-4.7',
|
|
tokens_in: 1200,
|
|
tokens_out: 800,
|
|
},
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.data.duration_ms).toBe(28854);
|
|
expect(result!.data.cost_usd).toBe(0.037);
|
|
expect(result!.data.model).toBe('glm-4.7');
|
|
expect(result!.data.tokens_in).toBe(1200);
|
|
expect(result!.data.tokens_out).toBe(800);
|
|
});
|
|
});
|
|
|
|
describe('covers all NeedleEventType values', () => {
|
|
const allEventTypes: NeedleEventType[] = [
|
|
'worker.started', 'worker.stopped', 'worker.errored', 'worker.exhausted', 'worker.idle',
|
|
'worker.state_transition', 'worker.boot.timeout', 'worker.handling.timeout',
|
|
'strand.evaluated', 'strand.skipped',
|
|
'bead.claim.attempted', 'bead.claim.succeeded', 'bead.claim.race_lost', 'bead.claim.failed',
|
|
'bead.released', 'bead.completed', 'bead.orphaned',
|
|
'bead.mitosis.evaluated', 'bead.mitosis.split', 'bead.mitosis.skipped',
|
|
'bead.unravel.analyzed', 'bead.unravel.skipped',
|
|
'agent.dispatched', 'agent.completed',
|
|
'agent.transform.spawned', 'agent.transform.exited', 'agent.transform.skipped',
|
|
'build.timeout', 'build.heartbeat',
|
|
'outcome.classified', 'outcome.handled',
|
|
'heartbeat.emitted', 'peer.stale', 'peer.crashed',
|
|
'health.check',
|
|
'mend.orphaned_lock_removed', 'mend.dependency_cleaned', 'mend.db_repaired', 'mend.db_rebuilt',
|
|
'mend.cycle_summary', 'effort.recorded', 'budget.warning', 'budget.stop',
|
|
'rate_limit.wait', 'rate_limit.allowed',
|
|
'verification.failed', 'verification.passed',
|
|
'reflect.started', 'reflect.consolidated', 'reflect.skipped',
|
|
'drift.started', 'drift.completed', 'drift.skipped',
|
|
'decision.started', 'decision.completed', 'decision.skipped',
|
|
'pulse.scanner_started', 'pulse.scanner_completed', 'pulse.scanner_failed',
|
|
'pulse.bead_created', 'pulse.skipped',
|
|
'rollback.completed',
|
|
'canary.started', 'canary.suite_completed', 'canary.promoted', 'canary.rejected',
|
|
'transform.started', 'transform.completed', 'transform.failed', 'transform.skipped',
|
|
'telemetry.sink_error', 'telemetry.otlp.dropped', 'telemetry.otlp.shutdown_timeout',
|
|
];
|
|
|
|
it('should parse every NeedleEventType without returning null', () => {
|
|
for (const eventType of allEventTypes) {
|
|
const line = JSON.stringify({
|
|
timestamp: '2026-04-08T20:10:43.000Z',
|
|
event_type: eventType,
|
|
worker_id: 'test-worker',
|
|
session_id: 'test-session',
|
|
sequence: 1,
|
|
data: {},
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.event_type).toBe(eventType);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('legacy NEEDLE format', () => {
|
|
it('should parse legacy NEEDLE format into NeedleEvent', () => {
|
|
const line = JSON.stringify({
|
|
ts: '2026-03-04T16:17:34.008Z',
|
|
event: 'worker.started',
|
|
session: 'forge-glm-test',
|
|
worker: {
|
|
runner: 'claude',
|
|
provider: 'code',
|
|
model: 'glm-4.7',
|
|
identifier: 'test',
|
|
},
|
|
data: { pid: 2789549, workspace: '/home/coder/forge' },
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.event_type).toBe('worker.started');
|
|
expect(result!.worker_id).toBe('claude-code-glm-4.7-test');
|
|
expect(result!.session_id).toBe('forge-glm-test');
|
|
expect(result!.timestamp).toBe('2026-03-04T16:17:34.008Z');
|
|
expect(result!.data.pid).toBe(2789549);
|
|
expect(result!.data.workspace).toBe('/home/coder/forge');
|
|
});
|
|
|
|
it('should parse flat legacy format into NeedleEvent', () => {
|
|
const line = JSON.stringify({
|
|
ts: 1709337600000,
|
|
worker: 'w-abc123',
|
|
level: 'info',
|
|
msg: 'Test message',
|
|
});
|
|
|
|
const result = parseNeedleEvent(line);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.event_type).toBe('Test message');
|
|
expect(result!.worker_id).toBe('w-abc123');
|
|
expect(result!.session_id).toBe('');
|
|
expect(result!.data.level).toBe('info');
|
|
});
|
|
});
|
|
|
|
describe('invalid inputs', () => {
|
|
it('should return null for empty string', () => {
|
|
expect(parseNeedleEvent('')).toBeNull();
|
|
});
|
|
|
|
it('should return null for whitespace-only string', () => {
|
|
expect(parseNeedleEvent(' \n\t ')).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-JSON string', () => {
|
|
expect(parseNeedleEvent('not valid json')).toBeNull();
|
|
});
|
|
|
|
it('should return null for malformed JSON', () => {
|
|
expect(parseNeedleEvent('{"ts": 123,')).toBeNull();
|
|
});
|
|
|
|
it('should return null for object missing required canonical fields', () => {
|
|
const line = JSON.stringify({ timestamp: '2026-04-08T20:10:43.000Z' });
|
|
expect(parseNeedleEvent(line)).toBeNull();
|
|
});
|
|
});
|
|
});
|