308 lines
12 KiB
JavaScript
308 lines
12 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
parseTranscript,
|
|
extractEnvironment,
|
|
extractTaskSize,
|
|
classifyPromptSignal,
|
|
} from './observer-transcript-parser.mjs';
|
|
|
|
// Build a JSONL transcript string from entry objects.
|
|
function jsonl(entries) {
|
|
return entries.map((e) => JSON.stringify(e)).join('\n');
|
|
}
|
|
|
|
function userPrompt(text, ts, sessionId = 's1') {
|
|
return { type: 'user', message: { role: 'user', content: text }, timestamp: ts, sessionId };
|
|
}
|
|
function assistantTurn(blocks, ts, sessionId = 's1') {
|
|
return { type: 'assistant', message: { role: 'assistant', content: blocks }, timestamp: ts, sessionId };
|
|
}
|
|
function toolResult(toolUseId, isError, ts, sessionId = 's1') {
|
|
return {
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'r', is_error: isError }],
|
|
},
|
|
timestamp: ts,
|
|
sessionId,
|
|
};
|
|
}
|
|
|
|
describe('parseTranscript', () => {
|
|
it('extracts task_id from sessionId in transcript', () => {
|
|
const t = jsonl([userPrompt('add a feature', '2026-05-19T10:00:00Z', 'sess-xyz')]);
|
|
expect(parseTranscript(t).task_id).toBe('sess-xyz');
|
|
});
|
|
|
|
it('falls back to provided sessionId when transcript has none', () => {
|
|
const t = jsonl([
|
|
{ type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z' },
|
|
]);
|
|
expect(parseTranscript(t, 'fallback-id').task_id).toBe('fallback-id');
|
|
});
|
|
|
|
it('extracts timestamps from the last turn', () => {
|
|
const t = jsonl([
|
|
userPrompt('first', '2026-05-19T09:00:00Z'),
|
|
userPrompt('second turn', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'text', text: 'done' }], '2026-05-19T10:05:00Z'),
|
|
]);
|
|
const ep = parseTranscript(t);
|
|
expect(ep.timestamps.started_at).toBe('2026-05-19T10:00:00Z');
|
|
expect(ep.timestamps.ended_at).toBe('2026-05-19T10:05:00Z');
|
|
});
|
|
|
|
it('emits skill_invoked events for Skill tool_use', () => {
|
|
const t = jsonl([
|
|
userPrompt('do tdd', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
const skillEvents = parseTranscript(t).events.filter((e) => e.kind === 'skill_invoked');
|
|
expect(skillEvents).toHaveLength(1);
|
|
expect(skillEvents[0].skill).toBe('superpowers:test-driven-development');
|
|
});
|
|
|
|
it('emits a tool_summary event with per-tool counts', () => {
|
|
const t = jsonl([
|
|
userPrompt('work', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[
|
|
{ type: 'tool_use', id: 't1', name: 'Read', input: {} },
|
|
{ type: 'tool_use', id: 't2', name: 'Read', input: {} },
|
|
{ type: 'tool_use', id: 't3', name: 'Bash', input: {} },
|
|
],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
|
|
expect(summary.counts).toEqual({ Read: 2, Bash: 1 });
|
|
});
|
|
|
|
it('node_chosen is the first skill invoked', () => {
|
|
const t = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('superpowers:brainstorming');
|
|
});
|
|
|
|
it('node_chosen is "direct" when no skill invoked', () => {
|
|
const t = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
|
]);
|
|
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct');
|
|
});
|
|
|
|
it('hard_floor invoked when a superpowers skill is used', () => {
|
|
const t = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:writing-plans' } }],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
const hf = parseTranscript(t).primary_rationale.hard_floor;
|
|
expect(hf.invoked).toBe(true);
|
|
expect(hf.rules).toContain('Pravila §12');
|
|
});
|
|
|
|
it('hard_floor not invoked for a non-superpowers skill', () => {
|
|
const t = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'claude-md-management:claude-md-improver' } }],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
expect(parseTranscript(t).primary_rationale.hard_floor.invoked).toBe(false);
|
|
});
|
|
|
|
it('classifies task from the user prompt text', () => {
|
|
const cls = (text) =>
|
|
parseTranscript(jsonl([userPrompt(text, '2026-05-19T10:00:00Z')])).primary_rationale.task_classification;
|
|
expect(cls('почини баг в логине')).toBe('bugfix');
|
|
expect(cls('добавь новую фичу экспорта')).toBe('feature');
|
|
expect(cls('отрефактори этот модуль')).toBe('refactor');
|
|
expect(cls('как это работает?')).toBe('question');
|
|
expect(cls('обнови документацию readme')).toBe('docs');
|
|
});
|
|
|
|
it('scopes to the last turn — earlier turns are excluded', () => {
|
|
const t = jsonl([
|
|
userPrompt('turn 1', '2026-05-19T09:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
|
|
'2026-05-19T09:01:00Z'
|
|
),
|
|
userPrompt('turn 2', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
|
]);
|
|
const ep = parseTranscript(t);
|
|
expect(ep.events.filter((e) => e.kind === 'skill_invoked')).toHaveLength(0);
|
|
expect(ep.primary_rationale.node_chosen).toBe('direct');
|
|
});
|
|
|
|
it('does not treat a tool_result message as a turn boundary', () => {
|
|
const t = jsonl([
|
|
userPrompt('the only real prompt', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
|
|
toolResult('t1', false, '2026-05-19T10:02:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Edit', input: {} }], '2026-05-19T10:03:00Z'),
|
|
]);
|
|
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
|
|
expect(summary.counts).toEqual({ Bash: 1, Edit: 1 });
|
|
});
|
|
|
|
it('emits error events for tool_result with is_error', () => {
|
|
const t = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
|
|
toolResult('t1', true, '2026-05-19T10:02:00Z'),
|
|
]);
|
|
expect(parseTranscript(t).events.filter((e) => e.kind === 'error')).toHaveLength(1);
|
|
});
|
|
|
|
it('skips broken JSONL lines without throwing', () => {
|
|
const t = [
|
|
JSON.stringify(userPrompt('go', '2026-05-19T10:00:00Z')),
|
|
'{ broken json',
|
|
JSON.stringify(assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z')),
|
|
].join('\n');
|
|
expect(() => parseTranscript(t)).not.toThrow();
|
|
expect(parseTranscript(t).events.find((e) => e.kind === 'tool_summary').counts).toEqual({ Read: 1 });
|
|
});
|
|
|
|
it('path_type is regulated with a superpowers skill, improvised otherwise', () => {
|
|
const reg = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn(
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
]);
|
|
const imp = jsonl([
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
|
]);
|
|
expect(parseTranscript(reg).path_type).toBe('regulated');
|
|
expect(parseTranscript(imp).path_type).toBe('improvised');
|
|
});
|
|
|
|
it('returns safe defaults for an empty transcript', () => {
|
|
const ep = parseTranscript('');
|
|
expect(ep.task_id).toBeTruthy();
|
|
expect(ep.primary_rationale.node_chosen).toBe('direct');
|
|
expect(ep.events).toEqual([]);
|
|
expect(ep.outcome).toBe('success');
|
|
});
|
|
|
|
it('produces a complete 7-field primary_rationale', () => {
|
|
const ep = parseTranscript(jsonl([userPrompt('go', '2026-05-19T10:00:00Z')]));
|
|
const r = ep.primary_rationale;
|
|
for (const f of [
|
|
'step',
|
|
'node_chosen',
|
|
'triggers_matched',
|
|
'candidates_considered',
|
|
'boundaries_applied',
|
|
'hard_floor',
|
|
'task_classification',
|
|
]) {
|
|
expect(r[f]).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('extractEnvironment', () => {
|
|
it('reads economy_level from the ECONOMY MODE marker', () => {
|
|
const entries = [
|
|
userPrompt('=== ECONOMY MODE: 0% (пользователь указал явно) ===\nfix it', '2026-05-19T10:00:00Z'),
|
|
];
|
|
expect(extractEnvironment(entries, 0).economy_level).toBe(0);
|
|
});
|
|
|
|
it('economy_level is null when no marker present', () => {
|
|
const entries = [userPrompt('just do it', '2026-05-19T10:00:00Z')];
|
|
expect(extractEnvironment(entries, 0).economy_level).toBeNull();
|
|
});
|
|
|
|
it('reads model from an assistant message', () => {
|
|
const entries = [
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-7', content: [] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1' },
|
|
];
|
|
expect(extractEnvironment(entries, 0).model).toBe('claude-opus-4-7');
|
|
});
|
|
|
|
it('post_compaction is true when an isCompactSummary entry precedes the turn', () => {
|
|
const entries = [
|
|
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' },
|
|
userPrompt('the real turn', '2026-05-19T10:00:00Z'),
|
|
];
|
|
expect(extractEnvironment(entries, 1).post_compaction).toBe(true);
|
|
});
|
|
|
|
it('post_compaction is false with no compaction marker', () => {
|
|
const entries = [userPrompt('turn one', '2026-05-19T09:00:00Z'), userPrompt('turn two', '2026-05-19T10:00:00Z')];
|
|
expect(extractEnvironment(entries, 1).post_compaction).toBe(false);
|
|
});
|
|
|
|
it('session_turn counts real user prompts up to and including the turn start', () => {
|
|
const entries = [
|
|
userPrompt('one', '2026-05-19T09:00:00Z'),
|
|
userPrompt('two', '2026-05-19T09:30:00Z'),
|
|
userPrompt('three', '2026-05-19T10:00:00Z'),
|
|
];
|
|
expect(extractEnvironment(entries, 2).session_turn).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('extractTaskSize', () => {
|
|
it('counts tool calls and unique file paths', () => {
|
|
const turn = [
|
|
assistantTurn(
|
|
[
|
|
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/a.js' } },
|
|
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/a.js' } },
|
|
{ type: 'tool_use', id: 't3', name: 'Write', input: { file_path: '/b.js' } },
|
|
{ type: 'tool_use', id: 't4', name: 'Bash', input: {} },
|
|
],
|
|
'2026-05-19T10:01:00Z'
|
|
),
|
|
];
|
|
const size = extractTaskSize(turn);
|
|
expect(size.tool_calls).toBe(4);
|
|
expect(size.files_touched).toBe(2);
|
|
expect(size.files.sort()).toEqual(['/a.js', '/b.js']);
|
|
});
|
|
|
|
it('returns zeros for an empty turn', () => {
|
|
expect(extractTaskSize([])).toEqual({ tool_calls: 0, files_touched: 0, files: [] });
|
|
});
|
|
});
|
|
|
|
describe('classifyPromptSignal', () => {
|
|
it('detects corrections', () => {
|
|
expect(classifyPromptSignal('не то, переделай')).toBe('correction');
|
|
expect(classifyPromptSignal('почему ты это сделал')).toBe('correction');
|
|
});
|
|
it('detects approvals', () => {
|
|
expect(classifyPromptSignal('ок, спасибо')).toBe('approval');
|
|
expect(classifyPromptSignal('готово, дальше')).toBe('approval');
|
|
});
|
|
it('detects a new task', () => {
|
|
expect(classifyPromptSignal('добавь новую фичу экспорта в CSV')).toBe('new_task');
|
|
});
|
|
it('falls back to neutral', () => {
|
|
expect(classifyPromptSignal('hmm')).toBe('neutral');
|
|
});
|
|
});
|