2026-06-15 08:06:08 +03:00
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
|
import {
|
|
|
|
|
|
parseTranscript,
|
|
|
|
|
|
extractEnvironment,
|
|
|
|
|
|
extractTaskSize,
|
|
|
|
|
|
classifyPromptSignal,
|
|
|
|
|
|
extractProcessEvents,
|
|
|
|
|
|
parseRoutingTag,
|
|
|
|
|
|
extractLastUserPromptText,
|
|
|
|
|
|
classifyTask,
|
|
|
|
|
|
extractTokenUsage,
|
|
|
|
|
|
extractMcpServers,
|
|
|
|
|
|
extractFileTypeDistribution,
|
|
|
|
|
|
classifyFilePath,
|
|
|
|
|
|
} from './observer-transcript-parser.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
describe('v4_signals + judge_spend_usd in episode', () => {
|
|
|
|
|
|
it('episode carries v4_signals block (default zero/null when no runtime files)', () => {
|
|
|
|
|
|
const ep = parseTranscript(JSON.stringify({
|
|
|
|
|
|
sessionId: 'no-runtime-sess',
|
|
|
|
|
|
message: { role: 'user', content: 'сделай фичу X' },
|
|
|
|
|
|
timestamp: '2026-05-31T10:00:00.000Z',
|
|
|
|
|
|
}), null, { routerStateBaseDir: tmpdir() });
|
|
|
|
|
|
expect(ep.v4_signals).toEqual({
|
|
|
|
|
|
rationalization_flag_count: 0, judge_verdict: null, safe_baseline_action: null, judge_calls: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(typeof ep.task_cost.judge_spend_usd).toBe('number');
|
|
|
|
|
|
expect(ep.schema_minor).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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('attaches chain_ref for a node that belongs to a chain', () => {
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('go', '2026-05-19T10:00:00Z'),
|
|
|
|
|
|
assistantTurn(
|
|
|
|
|
|
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'billing-audit' } }],
|
|
|
|
|
|
'2026-05-19T10:01:00Z'
|
|
|
|
|
|
),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(parseTranscript(t).primary_rationale.chain_ref).toEqual(['L13']);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('sets chain_ref null for a direct episode', () => {
|
|
|
|
|
|
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.chain_ref).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const errs = parseTranscript(t).events.filter((e) => e.kind === 'error');
|
|
|
|
|
|
expect(errs).toHaveLength(1);
|
|
|
|
|
|
expect(errs[0].tool).toBe('Bash');
|
|
|
|
|
|
expect(errs[0].summary).toBe('r');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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('unknown');
|
|
|
|
|
|
expect(ep.schema_version).toBe(4);
|
|
|
|
|
|
expect(ep.schema_minor).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('session_turn counts only prompts after the last compaction', () => {
|
|
|
|
|
|
const entries = [
|
|
|
|
|
|
userPrompt('old 1', '2026-05-19T08:00:00Z'),
|
|
|
|
|
|
userPrompt('old 2', '2026-05-19T08:30:00Z'),
|
|
|
|
|
|
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' },
|
|
|
|
|
|
userPrompt('after 1', '2026-05-19T09:30:00Z'),
|
|
|
|
|
|
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('session_turn counts from the LAST compaction when several are present', () => {
|
|
|
|
|
|
const entries = [
|
|
|
|
|
|
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's1' }, timestamp: '2026-05-19T08:00:00Z' },
|
|
|
|
|
|
userPrompt('mid 1', '2026-05-19T08:30:00Z'),
|
|
|
|
|
|
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's2' }, timestamp: '2026-05-19T09:00:00Z' },
|
|
|
|
|
|
userPrompt('after 1', '2026-05-19T09:30:00Z'),
|
|
|
|
|
|
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 widened correction phrases', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('не работает экспорт')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('сломал тесты, верни как было')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('опять не та колонка')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('всё ещё падает')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('откати последнюю правку')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('this is still not working')).toBe('correction');
|
|
|
|
|
|
expect(classifyPromptSignal('revert that change')).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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractProcessEvents', () => {
|
|
|
|
|
|
it('emits a hook_fired summary with per-hook counts and error count', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } },
|
|
|
|
|
|
{ attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } },
|
|
|
|
|
|
{ attachment: { type: 'hook_error', hookName: 'Stop:observer' } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const ev = extractProcessEvents(turn, 0, 0, 0).find((e) => e.kind === 'hook_fired');
|
|
|
|
|
|
expect(ev.counts).toEqual({ 'PreToolUse:Read': 2, 'Stop:observer': 1 });
|
|
|
|
|
|
expect(ev.errors).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits an interrupt event for [Request interrupted by user]', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'interrupt')).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits a retry event when an errored tool is used again later', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
|
|
|
|
|
|
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'retry')).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits a time_burn event when the turn exceeds the threshold', () => {
|
|
|
|
|
|
const ev = extractProcessEvents([], 0, 0, 1000000).find((e) => e.kind === 'time_burn');
|
|
|
|
|
|
expect(ev.duration_ms).toBe(1000000);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits a parse_gap event when the broken-line ratio is above threshold', () => {
|
|
|
|
|
|
const ev = extractProcessEvents([], 3, 10, 0).find((e) => e.kind === 'parse_gap');
|
|
|
|
|
|
expect(ev).toEqual({ kind: 'parse_gap', broken: 3, total: 10 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits nothing for a clean empty turn', () => {
|
|
|
|
|
|
expect(extractProcessEvents([], 0, 0, 0)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits unrecovered_error when the LAST tool_result in the turn is is_error', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
|
|
|
|
|
|
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does NOT emit unrecovered_error when the turn ends on a successful tool_result', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
|
|
|
|
|
|
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } },
|
|
|
|
|
|
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u2', is_error: false }] } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does NOT emit unrecovered_error for a turn with no tool_results at all', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just talking' }] } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseRoutingTag', () => {
|
|
|
|
|
|
it('parses a user_directed_method routing tag from assistant text', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
assistantTurn(
|
|
|
|
|
|
[{ type: 'text', text: 'ok\n<!-- routing: provenance=user_directed_method node=discovery-interview counterfactual=brainstorming -->' }],
|
|
|
|
|
|
'2026-05-19T10:01:00Z'
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(parseRoutingTag(turn)).toEqual({
|
|
|
|
|
|
kind: 'user_directed_method',
|
|
|
|
|
|
node: 'discovery-interview',
|
|
|
|
|
|
claude_would_have_chosen: 'brainstorming',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('returns null when no tag is present', () => {
|
|
|
|
|
|
const turn = [assistantTurn([{ type: 'text', text: 'plain answer' }], '2026-05-19T10:01:00Z')];
|
|
|
|
|
|
expect(parseRoutingTag(turn)).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — v4 episode (Phase 2 Task 15 bump)', () => {
|
|
|
|
|
|
it('produces schema_version 4 and all v2+ fields', () => {
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('=== ECONOMY MODE: 0% ===\nдобавь фичу', '2026-05-19T10:00:00Z', 'sess-v2'),
|
|
|
|
|
|
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }], '2026-05-19T10:01:00Z', 'sess-v2'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const ep = parseTranscript(t);
|
|
|
|
|
|
expect(ep.schema_version).toBe(4);
|
|
|
|
|
|
expect(ep.schema_minor).toBe(4);
|
|
|
|
|
|
expect('classifier_output' in ep).toBe(true);
|
|
|
|
|
|
expect('degraded_mode' in ep).toBe(true);
|
|
|
|
|
|
expect('classifier_model' in ep.environment).toBe(true);
|
|
|
|
|
|
expect(ep.task_ref).toBe('sess-v2');
|
|
|
|
|
|
expect(ep.outcome).toBe('unknown');
|
|
|
|
|
|
expect(ep.prompt_signal).toBe('new_task');
|
|
|
|
|
|
expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
|
|
|
|
|
|
expect(ep.environment.economy_level).toBe(0);
|
|
|
|
|
|
expect(ep.task_size).toEqual({ tool_calls: 1, files_touched: 1, files: ['/x.js'] });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('records decision_provenance from a routing tag', () => {
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('запусти discovery-interview', '2026-05-19T10:00:00Z', 'sess-tag'),
|
|
|
|
|
|
assistantTurn(
|
|
|
|
|
|
[{ type: 'text', text: '<!-- routing: provenance=user_directed_method node=discovery-interview counterfactual=brainstorming -->' }],
|
|
|
|
|
|
'2026-05-19T10:01:00Z',
|
|
|
|
|
|
'sess-tag'
|
|
|
|
|
|
),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const ep = parseTranscript(t);
|
|
|
|
|
|
expect(ep.decision_provenance.kind).toBe('user_directed_method');
|
|
|
|
|
|
expect(ep.decision_provenance.claude_would_have_chosen).toBe('brainstorming');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractLastUserPromptText', () => {
|
|
|
|
|
|
it('returns the text of the last real user prompt', () => {
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('first turn', '2026-05-19T09:00:00Z'),
|
|
|
|
|
|
userPrompt('second and last', '2026-05-19T10:00:00Z'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(extractLastUserPromptText(t)).toBe('second and last');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — user_chose_from_options', () => {
|
|
|
|
|
|
it('classifies as user_chose_from_options when last assistant offered options and user picked one', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'continue task' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [{ type: 'text', text: 'Choose:\n1. First option\n2. Second option\n3. Third option' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:10.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: '2 делаем' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:11.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.decision_provenance).toEqual({
|
|
|
|
|
|
kind: 'user_chose_from_options',
|
|
|
|
|
|
node: 'Second option',
|
|
|
|
|
|
options_offered: ['First option', 'Second option', 'Third option'],
|
|
|
|
|
|
claude_would_have_chosen: 'First option',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('falls back to autonomous when last assistant had no options', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'just a paragraph' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:10.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: '2 делаем' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:11.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('routing-tag user_directed_method preserved when no choice detected', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'запусти brainstorming' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [{ type: 'text', text: 'ok\n<!-- routing: provenance=user_directed_method node=brainstorming counterfactual=writing-plans -->' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.decision_provenance.kind).toBe('user_directed_method');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — synthetic user messages skipped', () => {
|
|
|
|
|
|
it('captures economy_level from genuine prompt even when skill-content message follows', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'почини баг в парсере' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'attachment',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.500Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
attachment: {
|
|
|
|
|
|
type: 'hook_additional_context',
|
|
|
|
|
|
hookName: 'UserPromptSubmit',
|
|
|
|
|
|
content: ['=== ECONOMY MODE: 5% (тест) ===\nинструкции режима...'],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'смотрю' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:02.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'Base directory for this skill: C:\\path\\skill\n\n# Some Skill\nbody' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:03.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.environment.economy_level).toBe(5);
|
|
|
|
|
|
expect(ep.primary_rationale.task_classification).toBe('bugfix');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('extractLastUserPromptText skips skill-content and returns genuine prompt', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'добавь колонку Город' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:02.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'Base directory for this skill: C:\\x\n# Skill body' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
expect(extractLastUserPromptText(lines)).toBe('добавь колонку Город');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('extractLastUserPromptText skips local-command output and interrupt markers', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'сделай X' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: '[Request interrupted by user]' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:02.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: '<local-command-stdout>some output</local-command-stdout>' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
expect(extractLastUserPromptText(lines)).toBe('сделай X');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — AskUserQuestion in-turn choice', () => {
|
|
|
|
|
|
it('classifies user_chose_from_options when an AskUserQuestion option was clicked', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: 'построй фичу' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [{ type: 'tool_use', name: 'AskUserQuestion', input: {} }],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:05.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] },
|
|
|
|
|
|
toolUseResult: {
|
|
|
|
|
|
questions: [
|
|
|
|
|
|
{
|
|
|
|
|
|
question: 'Каким режимом?',
|
|
|
|
|
|
options: [{ label: 'Subagent-Driven' }, { label: 'Inline execution' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
answers: { 'Каким режимом?': 'Inline execution' },
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:06.000Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.decision_provenance).toEqual({
|
|
|
|
|
|
kind: 'user_chose_from_options',
|
|
|
|
|
|
node: 'Inline execution',
|
|
|
|
|
|
options_offered: ['Subagent-Driven', 'Inline execution'],
|
|
|
|
|
|
claude_would_have_chosen: 'Subagent-Driven',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — uuid-dedup for duplicated snapshots (quirk #101)', () => {
|
|
|
|
|
|
// Claude Code's transcript file accumulates duplicated context-rebuild
|
|
|
|
|
|
// snapshots; the same entry is re-printed with the SAME `uuid`. Without
|
|
|
|
|
|
// dedup, session_turn / task_size / events double-count and become
|
|
|
|
|
|
// non-monotonic across episodes parsed at different file-growth states.
|
|
|
|
|
|
// Root fix: dedup by uuid in parseLines.
|
|
|
|
|
|
|
|
|
|
|
|
it('collapses duplicated-uuid user prompts (session_turn counts once)', () => {
|
|
|
|
|
|
const dup = 'aaaa-bbbb-cccc';
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: dup,
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'hi' },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: dup,
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'hi' },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.environment.session_turn).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('preserves distinct-uuid entries (no over-dedup)', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: 'one',
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'first' },
|
|
|
|
|
|
timestamp: '2026-05-19T09:00:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: 'two',
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'second' },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.environment.session_turn).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('entries without uuid pass through unchanged (synthetic fixtures unaffected)', () => {
|
|
|
|
|
|
// Existing tests build entries without `uuid` — must still work.
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('one', '2026-05-19T09:00:00Z'),
|
|
|
|
|
|
userPrompt('two', '2026-05-19T10:00:00Z'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(parseTranscript(t).environment.session_turn).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('collapses duplicated-uuid assistant turns (task_size counted once)', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: 'u-prompt',
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'go' },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: 'u-asst',
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } },
|
|
|
|
|
|
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-19T10:01:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
// EXACT duplicate of the assistant entry — snapshot rebuild artifact
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
uuid: 'u-asst',
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } },
|
|
|
|
|
|
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-19T10:01:00Z',
|
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(lines, 'fallback');
|
|
|
|
|
|
expect(ep.task_size.tool_calls).toBe(2);
|
|
|
|
|
|
expect(ep.task_size.files_touched).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractEnvironment — parallel_session narrowed to tool_result evidence', () => {
|
|
|
|
|
|
// Collision detection scans ONLY tool_result content (real command output).
|
|
|
|
|
|
// Prose mentions in user prompts / assistant text — including analysis text
|
|
|
|
|
|
// that references collision phrases — must not trigger.
|
|
|
|
|
|
const userMsg = (text) => ({
|
|
|
|
|
|
message: { role: 'user', content: text },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:00.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
const assistantText = (text) => ({
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:01.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
const toolResultText = (text, isError = false) => ({
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: [{ type: 'tool_result', tool_use_id: 't1', content: text, is_error: isError }],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:02.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is false when a USER prompt only mentions a collision marker', () => {
|
|
|
|
|
|
const env = extractEnvironment([userMsg('в моём ходе появился чужой staged-файл — что делать?')], 0);
|
|
|
|
|
|
expect(env.parallel_session).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is false when ASSISTANT prose mentions a collision marker (e.g., analysis text)', () => {
|
|
|
|
|
|
const env = extractEnvironment(
|
|
|
|
|
|
[userMsg('go'), assistantText('я писал про чужой staged и foreign git index в разборе')],
|
|
|
|
|
|
0
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(env.parallel_session).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is true when a tool_result (Bash output) contains "foreign git index"', () => {
|
|
|
|
|
|
const env = extractEnvironment(
|
|
|
|
|
|
[
|
|
|
|
|
|
userMsg('go'),
|
|
|
|
|
|
toolResultText('fatal: another git process is running\nforeign git index detected', true),
|
|
|
|
|
|
],
|
|
|
|
|
|
0
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(env.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is true when a tool_result contains "чужой staged"', () => {
|
|
|
|
|
|
const env = extractEnvironment(
|
|
|
|
|
|
[userMsg('go'), toolResultText('error: чужой staged-файл присутствует в индексе')],
|
|
|
|
|
|
0
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(env.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is false when there are no tool_result entries at all', () => {
|
|
|
|
|
|
const env = extractEnvironment([userMsg('говорим о параллельных сессиях вообще')], 0);
|
|
|
|
|
|
expect(env.parallel_session).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('handles tool_result with content as a structured array of text blocks', () => {
|
|
|
|
|
|
const env = extractEnvironment(
|
|
|
|
|
|
[
|
|
|
|
|
|
userMsg('go'),
|
|
|
|
|
|
{
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'tool_result',
|
|
|
|
|
|
tool_use_id: 't1',
|
|
|
|
|
|
content: [{ type: 'text', text: 'index.lock exists, another process is holding it' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-19T10:00:02.000Z',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
0
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(env.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('classifyTask — extended dictionary (Task 1)', () => {
|
|
|
|
|
|
it('classifies analysis prompts', () => {
|
|
|
|
|
|
expect(classifyTask('проанализируй данные наблюдателя')).toBe('analysis');
|
|
|
|
|
|
expect(classifyTask('review the recent changes')).toBe('analysis');
|
|
|
|
|
|
expect(classifyTask('посмотри что в логах')).toBe('analysis');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies memory-sync prompts', () => {
|
|
|
|
|
|
expect(classifyTask('обнови эталон проекта')).toBe('memory-sync');
|
|
|
|
|
|
expect(classifyTask('sync memory с последним push')).toBe('memory-sync');
|
|
|
|
|
|
expect(classifyTask('обнови MEMORY.md')).toBe('memory-sync');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies regulatory-bump prompts', () => {
|
|
|
|
|
|
expect(classifyTask('обнови CLAUDE.md после правки')).toBe('regulatory-bump');
|
|
|
|
|
|
expect(classifyTask('правка Pravila §16')).toBe('regulatory-bump');
|
|
|
|
|
|
expect(classifyTask('обнови PSR_v1 v3.18')).toBe('regulatory-bump');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies release prompts', () => {
|
|
|
|
|
|
expect(classifyTask('push origin main')).toBe('release');
|
|
|
|
|
|
expect(classifyTask('merge feature-branch')).toBe('release');
|
|
|
|
|
|
expect(classifyTask('сделай release v2')).toBe('release');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('does NOT classify a plain commit (no push/release) as release', () => {
|
|
|
|
|
|
expect(classifyTask('commit the auth changes')).not.toBe('release');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies cleanup prompts', () => {
|
|
|
|
|
|
expect(classifyTask('убери временные файлы')).toBe('cleanup');
|
|
|
|
|
|
expect(classifyTask('cleanup тулчейн')).toBe('cleanup');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies monitoring prompts', () => {
|
|
|
|
|
|
expect(classifyTask('проверь состояние портала')).toBe('monitoring');
|
|
|
|
|
|
expect(classifyTask('status of CI')).toBe('monitoring');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies planning prompts', () => {
|
|
|
|
|
|
expect(classifyTask('план рефакторинга биллинга')).toBe('planning');
|
|
|
|
|
|
expect(classifyTask('design новую фичу')).toBe('planning');
|
|
|
|
|
|
expect(classifyTask('обсудим архитектуру')).toBe('planning');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('preserves existing classes — refactor stays refactor', () => {
|
|
|
|
|
|
expect(classifyTask('рефактор биллинг-сервиса')).toBe('refactor');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('preserves existing classes — bugfix stays bugfix', () => {
|
|
|
|
|
|
expect(classifyTask('почини баг в logger')).toBe('bugfix');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractTokenUsage (Task 2)', () => {
|
|
|
|
|
|
it('sums input/output/cache fields across multiple assistant messages', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 } } },
|
|
|
|
|
|
{ message: { usage: { input_tokens: 8, output_tokens: 3, cache_read_input_tokens: 80, cache_creation_input_tokens: 20 } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractTokenUsage(turn)).toEqual({
|
|
|
|
|
|
input_tokens: 18, output_tokens: 8, cache_read_input_tokens: 180,
|
|
|
|
|
|
cache_creation_input_tokens: 70, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
|
|
|
|
|
|
classifier_input_tokens: 0, classifier_output_tokens: 0,
|
|
|
|
|
|
self_assessment_input_tokens: 0, self_assessment_output_tokens: 0,
|
|
|
|
|
|
reviewer_input_tokens: 0, reviewer_output_tokens: 0,
|
|
|
|
|
|
reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
it('captures server_tool_use bonus fields (web_search/web_fetch)', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { usage: { input_tokens: 5, output_tokens: 2, server_tool_use: { web_search_requests: 3, web_fetch_requests: 1 } } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const result = extractTokenUsage(turn);
|
|
|
|
|
|
expect(result.web_search_requests).toBe(3);
|
|
|
|
|
|
expect(result.web_fetch_requests).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('captures iterations (extended-thinking detector)', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { usage: { input_tokens: 100, output_tokens: 50, iterations: 4 } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractTokenUsage(turn).iterations).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns zero-filled object when no usage present', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: {} },
|
|
|
|
|
|
{ message: { usage: null } },
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractTokenUsage(turn)).toEqual({
|
|
|
|
|
|
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
|
|
|
|
|
|
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
|
|
|
|
|
|
classifier_input_tokens: 0, classifier_output_tokens: 0,
|
|
|
|
|
|
self_assessment_input_tokens: 0, self_assessment_output_tokens: 0,
|
|
|
|
|
|
reviewer_input_tokens: 0, reviewer_output_tokens: 0,
|
|
|
|
|
|
reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
it('handles empty/null turn safely', () => {
|
|
|
|
|
|
const zeroShape = {
|
|
|
|
|
|
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
|
|
|
|
|
|
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
|
|
|
|
|
|
classifier_input_tokens: 0, classifier_output_tokens: 0,
|
|
|
|
|
|
self_assessment_input_tokens: 0, self_assessment_output_tokens: 0,
|
|
|
|
|
|
reviewer_input_tokens: 0, reviewer_output_tokens: 0,
|
|
|
|
|
|
reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
expect(extractTokenUsage([])).toEqual(zeroShape);
|
|
|
|
|
|
expect(extractTokenUsage(null)).toEqual(zeroShape);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safely skips entries where usage is a non-object primitive (defensive guard)', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { usage: 42 } }, // malformed — usage as primitive
|
|
|
|
|
|
{ message: { usage: { input_tokens: 5, output_tokens: 3 } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const r = extractTokenUsage(turn);
|
|
|
|
|
|
expect(r.input_tokens).toBe(5);
|
|
|
|
|
|
expect(r.output_tokens).toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — task_cost integration (Task 2)', () => {
|
|
|
|
|
|
it('attaches task_cost to a v2 episode', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'implement feature X' }] } }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], usage: { input_tokens: 42, output_tokens: 7 } } }),
|
|
|
|
|
|
];
|
|
|
|
|
|
const result = parseTranscript(lines.join('\n'));
|
|
|
|
|
|
expect(result).not.toBeNull();
|
|
|
|
|
|
expect(result.task_cost).toBeDefined();
|
|
|
|
|
|
expect(result.task_cost.input_tokens).toBe(42);
|
|
|
|
|
|
expect(result.task_cost.output_tokens).toBe(7);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('attaches zero-filled task_cost when no usage in transcript', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'do something' }] } }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }),
|
|
|
|
|
|
];
|
|
|
|
|
|
const result = parseTranscript(lines.join('\n'));
|
|
|
|
|
|
expect(result).not.toBeNull();
|
|
|
|
|
|
expect(result.task_cost).toEqual({
|
|
|
|
|
|
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
|
|
|
|
|
|
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
|
|
|
|
|
|
classifier_input_tokens: 0, classifier_output_tokens: 0,
|
|
|
|
|
|
self_assessment_input_tokens: 0, self_assessment_output_tokens: 0,
|
|
|
|
|
|
reviewer_input_tokens: 0, reviewer_output_tokens: 0,
|
|
|
|
|
|
reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0,
|
|
|
|
|
|
judge_spend_usd: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
import { extractAskUserQuestionEvents } from './observer-transcript-parser.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractAskUserQuestionEvents (Task 4)', () => {
|
|
|
|
|
|
it('records "option" when user picked an offered label exactly', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
|
|
|
|
|
|
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
|
|
|
|
|
|
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
|
|
|
|
answers: { q1: 'A' } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const evs = extractAskUserQuestionEvents(turn);
|
|
|
|
|
|
expect(evs).toHaveLength(1);
|
|
|
|
|
|
expect(evs[0].kind).toBe('ask_user_question');
|
|
|
|
|
|
expect(evs[0].question_count).toBe(1);
|
|
|
|
|
|
expect(evs[0].answer_kind).toBe('option');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('records "custom" when user wrote free-text Other', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
|
|
|
|
|
|
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
|
|
|
|
|
|
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
|
|
|
|
answers: { q1: 'C — мой вариант' } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const evs = extractAskUserQuestionEvents(turn);
|
|
|
|
|
|
expect(evs[0].answer_kind).toBe('custom');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('records "no_answer" when answer key missing', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
|
|
|
|
answers: {} } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const evs = extractAskUserQuestionEvents(turn);
|
|
|
|
|
|
expect(evs[0].answer_kind).toBe('no_answer');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('reports question_count from toolUseResult.questions length', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
{ toolUseResult: { questions: [
|
|
|
|
|
|
{ question: 'q1', options: [{ label: 'A' }] },
|
|
|
|
|
|
{ question: 'q2', options: [{ label: 'B' }] },
|
|
|
|
|
|
], answers: { q1: 'A', q2: 'B' } } },
|
|
|
|
|
|
];
|
|
|
|
|
|
const evs = extractAskUserQuestionEvents(turn);
|
|
|
|
|
|
expect(evs).toHaveLength(2); // one event per question
|
|
|
|
|
|
expect(evs[0].question_count).toBe(2);
|
|
|
|
|
|
expect(evs[1].question_count).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty for a turn without AskUserQuestion', () => {
|
|
|
|
|
|
expect(extractAskUserQuestionEvents([{ message: { content: [{ type: 'tool_use', name: 'Read' }] } }])).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('handles null/undefined turn safely', () => {
|
|
|
|
|
|
expect(extractAskUserQuestionEvents(null)).toEqual([]);
|
|
|
|
|
|
expect(extractAskUserQuestionEvents(undefined)).toEqual([]);
|
|
|
|
|
|
expect(extractAskUserQuestionEvents([])).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — ask_user_question events (Task 4)', () => {
|
|
|
|
|
|
it('emits ask_user_question event from AskUserQuestion + toolUseResult', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's1' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'AskUserQuestion',
|
|
|
|
|
|
input: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }] } },
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 't1' },
|
|
|
|
|
|
] }, toolUseResult: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }],
|
|
|
|
|
|
answers: { q: 'X' } }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const aq = ep.events.filter((e) => e.kind === 'ask_user_question');
|
|
|
|
|
|
expect(aq).toHaveLength(1);
|
|
|
|
|
|
expect(aq[0].answer_kind).toBe('option');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
extractTriggers,
|
|
|
|
|
|
extractCandidates,
|
|
|
|
|
|
extractBoundaries,
|
|
|
|
|
|
} from './observer-transcript-parser.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
describe('reasoning capture heuristics (Task 6)', () => {
|
|
|
|
|
|
const mkTurn = (txt) => [{ message: { role: 'assistant', content: [{ type: 'text', text: txt }] } }];
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractTriggers', () => {
|
|
|
|
|
|
it('finds Pravila §N references', () => {
|
|
|
|
|
|
expect(extractTriggers(mkTurn('per Pravila §12.2 hard-rule'))).toContain('Pravila §12.2');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('finds ADR references', () => {
|
|
|
|
|
|
expect(extractTriggers(mkTurn('see ADR-011 anchor'))).toContain('ADR-011');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('finds PSR_v1 R refs', () => {
|
|
|
|
|
|
expect(extractTriggers(mkTurn('PSR_v1 R10.1 requires it'))).toContain('PSR_v1 R10.1');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('finds routing-off-phase L refs from canonical form', () => {
|
|
|
|
|
|
expect(extractTriggers(mkTurn('routing-off-phase L12 chain'))).toContain('routing-off-phase L12');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('finds hard-rule / hard-floor (case-insensitive)', () => {
|
|
|
|
|
|
const res = extractTriggers(mkTurn('this is a hard-rule per §15'));
|
|
|
|
|
|
expect(res.some((t) => t.toLowerCase().includes('hard-rule'))).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('deduplicates repeated triggers', () => {
|
|
|
|
|
|
const res = extractTriggers(mkTurn('Pravila §16 and Pravila §16 again'));
|
|
|
|
|
|
expect(res.filter((t) => t === 'Pravila §16')).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty for plain prose', () => {
|
|
|
|
|
|
expect(extractTriggers(mkTurn('just plain text'))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safe on null/empty', () => {
|
|
|
|
|
|
expect(extractTriggers(null)).toEqual([]);
|
|
|
|
|
|
expect(extractTriggers([])).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractCandidates', () => {
|
|
|
|
|
|
// Only items that look like router-node identifiers are accepted:
|
|
|
|
|
|
// - a known node from tools/observer-known-nodes.txt
|
|
|
|
|
|
// - a key from tools/observer-chain-map.json (e.g. superpowers:brainstorming)
|
|
|
|
|
|
// - a tooling ID matching ^#\d+$ (Прил. Н)
|
|
|
|
|
|
// - the sentinel "direct"
|
|
|
|
|
|
// Free-form prose bullets / numbered procedure steps / code snippets are rejected.
|
|
|
|
|
|
it('extracts numbered options that are known node names', () => {
|
|
|
|
|
|
const c = extractCandidates(
|
|
|
|
|
|
mkTurn('1. brainstorming\n2. subagent-driven-development\n3. direct')
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(c).toEqual(['brainstorming', 'subagent-driven-development', 'direct']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('accepts namespaced plugin:skill form from the chain map', () => {
|
|
|
|
|
|
const c = extractCandidates(
|
|
|
|
|
|
mkTurn('1. superpowers:brainstorming\n2. claude-md-management:claude-md-improver')
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(c).toEqual(['superpowers:brainstorming', 'claude-md-management:claude-md-improver']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('accepts tooling IDs like #25 / #74', () => {
|
|
|
|
|
|
expect(extractCandidates(mkTurn('1. #25\n2. #74'))).toEqual(['#25', '#74']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('strips simple markdown wrappers before checking', () => {
|
|
|
|
|
|
const c = extractCandidates(
|
|
|
|
|
|
mkTurn('1. **brainstorming**\n2. `writing-plans`\n3. discovery-interview')
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(c).toEqual(['brainstorming', 'writing-plans', 'discovery-interview']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('drops free-form prose bullets even when ≥2 are present', () => {
|
|
|
|
|
|
// Repro from docs/observer/episodes-2026-05.jsonl: analysis bullets with
|
|
|
|
|
|
// bold-prefix sentence text were going straight into candidates_considered.
|
|
|
|
|
|
const text =
|
|
|
|
|
|
'- **Hard-floor работает только для §12 Superpowers** (14 раз). §14/§15 в журнале не оставили следов.\n' +
|
|
|
|
|
|
'- **На feature/planning я не ищу триггеры** — 0% матча.\n' +
|
|
|
|
|
|
'- **Метка `regulated` врёт** в 79% случаев — нет настоящего применения границ.';
|
|
|
|
|
|
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('drops numbered procedure-step text (real episode repro)', () => {
|
|
|
|
|
|
const text =
|
|
|
|
|
|
'1. **Hard-floor check** — Pravila §12 (Superpowers) / §14 (Queen) / §15.\n' +
|
|
|
|
|
|
'2. **Классификация** — определяю тип задачи (feature/bugfix/planning).\n' +
|
|
|
|
|
|
'3. **Trigger-based node selection** — по реестру Tooling Прил. Н §4.X.\n' +
|
|
|
|
|
|
'4. **Canonical chain check** — смотрю L1-L15.\n' +
|
|
|
|
|
|
'5. **Execution** — иду делать.';
|
|
|
|
|
|
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('drops code-snippet bullets (regex patterns etc.)', () => {
|
|
|
|
|
|
const text =
|
|
|
|
|
|
'- regex `(?:^|[\\s\\"\\\'])(tools\\/[\\w-]+\\.(?:mjs|py|sh))` → имя файла.\n' +
|
|
|
|
|
|
'- fallback `inline:<sha256(command).slice(0,16)>` — стабильно.';
|
|
|
|
|
|
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('filters a mixed list down to just the real nodes', () => {
|
|
|
|
|
|
const text =
|
|
|
|
|
|
'1. brainstorming\n' +
|
|
|
|
|
|
'2. resolver + tests\n' +
|
|
|
|
|
|
'3. discovery-interview\n' +
|
|
|
|
|
|
'4. parser extension + tests + smoke';
|
|
|
|
|
|
expect(extractCandidates(mkTurn(text))).toEqual(['brainstorming', 'discovery-interview']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty when only one real node survives the filter', () => {
|
|
|
|
|
|
// ≥2 raw items but only 1 known-node → not enough signal, drop.
|
|
|
|
|
|
expect(extractCandidates(mkTurn('1. brainstorming\n2. некий текст'))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('prefers numbered over bullets when both lists contain known nodes', () => {
|
|
|
|
|
|
const c = extractCandidates(
|
|
|
|
|
|
mkTurn('1. brainstorming\n2. writing-plans\n- discovery-interview\n- regression')
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(c).toEqual(['brainstorming', 'writing-plans']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty for prose', () => {
|
|
|
|
|
|
expect(extractCandidates(mkTurn('просто текст'))).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safe on null/empty', () => {
|
|
|
|
|
|
expect(extractCandidates(null)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractBoundaries', () => {
|
|
|
|
|
|
it('finds ADR + PSR + Pravila refs', () => {
|
|
|
|
|
|
const b = extractBoundaries(mkTurn('per ADR-011 + PSR_v1 R16 + Pravila §16.2'));
|
|
|
|
|
|
expect(b).toContain('ADR-011');
|
|
|
|
|
|
expect(b.some((x) => x.includes('PSR_v1 R16'))).toBe(true);
|
|
|
|
|
|
expect(b).toContain('Pravila §16.2');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('finds routing-off-phase L refs', () => {
|
|
|
|
|
|
expect(extractBoundaries(mkTurn('chain L12 fires'))).toEqual(expect.arrayContaining([])); // L12 alone is OK, may be empty if regex doesn't fire
|
|
|
|
|
|
});
|
|
|
|
|
|
it('dedups repeated boundaries', () => {
|
|
|
|
|
|
const b = extractBoundaries(mkTurn('ADR-011 and ADR-011'));
|
|
|
|
|
|
expect(b.filter((x) => x === 'ADR-011')).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safe on null/empty', () => {
|
|
|
|
|
|
expect(extractBoundaries(null)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — heuristic primary_rationale (Task 6)', () => {
|
|
|
|
|
|
it('populates triggers_matched / candidates_considered / boundaries_applied', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's1' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'text', text: 'per Pravila §12.2 hard-rule\n1. brainstorming\n2. direct\nADR-011 applies.' }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2');
|
|
|
|
|
|
expect(ep.primary_rationale.candidates_considered).toContain('brainstorming');
|
|
|
|
|
|
expect(ep.primary_rationale.boundaries_applied).toContain('ADR-011');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('error event differentiation (Task 7)', () => {
|
|
|
|
|
|
it('attributes error to the failing tool and captures summary', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: {} }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: 'ENOENT: no such file or directory' }
|
|
|
|
|
|
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const errorEvents = ep.events.filter((e) => e.kind === 'error');
|
|
|
|
|
|
expect(errorEvents).toHaveLength(1);
|
|
|
|
|
|
expect(errorEvents[0].tool).toBe('Bash');
|
|
|
|
|
|
expect(errorEvents[0].summary).toContain('ENOENT');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('attributes error to "unknown" when tool_use_id not seen', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 'orphan', is_error: true, content: 'mystery error' }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const errs = ep.events.filter((e) => e.kind === 'error');
|
|
|
|
|
|
expect(errs[0].tool).toBe('unknown');
|
|
|
|
|
|
expect(errs[0].summary).toBe('mystery error');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('truncates summary at 80 chars', () => {
|
|
|
|
|
|
const longText = 'a'.repeat(200);
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 'tu1', name: 'Read', input: {} }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: longText }
|
|
|
|
|
|
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const e = ep.events.find((x) => x.kind === 'error');
|
|
|
|
|
|
expect(e.summary.length).toBe(80);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('handles structured content array (text blocks)', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 'tu1', name: 'Edit', input: {} }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: [{ type: 'text', text: 'String to replace not found' }] }
|
|
|
|
|
|
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const e = ep.events.find((x) => x.kind === 'error');
|
|
|
|
|
|
expect(e.tool).toBe('Edit');
|
|
|
|
|
|
expect(e.summary).toContain('String to replace');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('promptText strips <system-reminder> blocks (Task 8)', () => {
|
|
|
|
|
|
it('classifyTask is not polluted by reminder content', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user',
|
|
|
|
|
|
content: '<system-reminder>почему как что зачем</system-reminder>\nрефактор биллинга'
|
|
|
|
|
|
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.primary_rationale.task_classification).toBe('refactor');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('multiline system-reminder is stripped', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user',
|
|
|
|
|
|
content: '<system-reminder>\nline 1\nline 2 with почему\n</system-reminder>\nfix баг'
|
|
|
|
|
|
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.primary_rationale.task_classification).toBe('bugfix');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('multiple system-reminders all stripped', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user',
|
|
|
|
|
|
content: '<system-reminder>почему</system-reminder>middle<system-reminder>как</system-reminder>создай фичу'
|
|
|
|
|
|
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.primary_rationale.task_classification).toBe('feature');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('content array form also stripped', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user',
|
|
|
|
|
|
content: [{ type: 'text', text: '<system-reminder>почему как</system-reminder> рефактор' }]
|
|
|
|
|
|
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.primary_rationale.task_classification).toBe('refactor');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('classifyPromptSignal — extended dictionary (Task 9)', () => {
|
|
|
|
|
|
it('detects "не совсем" as correction', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('не совсем то')).toBe('correction');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "wrong direction" as correction', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('wrong direction here')).toBe('correction');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "другое" as correction', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('другое решение нужно')).toBe('correction');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "класс" as approval', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('класс')).toBe('approval');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "well done" as approval', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('well done')).toBe('approval');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "nice" as approval', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('nice')).toBe('approval');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "теперь" prefix as new_task', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('теперь сделай биллинг')).toBe('new_task');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "далее" prefix as new_task', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('далее переходим к настройкам')).toBe('new_task');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('detects "next" prefix as new_task', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('next step please')).toBe('new_task');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('preserves existing — neutral for nondescript text', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('некий случайный текст')).toBe('neutral');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('preserves existing — correction "переделай"', () => {
|
|
|
|
|
|
expect(classifyPromptSignal('переделай это')).toBe('correction');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
import { parseReasoningTag } from './observer-transcript-parser.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseReasoningTag (Task 11)', () => {
|
|
|
|
|
|
it('parses opt-in reasoning tag from assistant text', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'text', text: '<!-- reasoning: triggers="Pravila §12.2;ADR-011" candidates="brain-retro;brainstorming" boundaries="Pravila §16" -->\nAnswer.' }
|
|
|
|
|
|
] } }];
|
|
|
|
|
|
const tag = parseReasoningTag(turn);
|
|
|
|
|
|
expect(tag).toEqual({
|
|
|
|
|
|
triggers: ['Pravila §12.2', 'ADR-011'],
|
|
|
|
|
|
candidates: ['brain-retro', 'brainstorming'],
|
|
|
|
|
|
boundaries: ['Pravila §16'],
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns null when no tag present', () => {
|
|
|
|
|
|
expect(parseReasoningTag([{ message: { role: 'assistant', content: [{ type: 'text', text: 'plain' }] } }])).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safe on null/empty turn', () => {
|
|
|
|
|
|
expect(parseReasoningTag(null)).toBeNull();
|
|
|
|
|
|
expect(parseReasoningTag([])).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
it('skips non-text blocks', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Read' }] } }];
|
|
|
|
|
|
expect(parseReasoningTag(turn)).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', () => {
|
|
|
|
|
|
it('merges tag triggers into heuristic triggers (deduped)', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'text', text: 'Pravila §12.2 hard-rule\n<!-- reasoning: triggers="Pravila §12.2;ADR-011" candidates="brain-retro" boundaries="Pravila §16" -->' }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
// heuristic finds 'Pravila §12.2' from text + tag adds 'ADR-011'; dedup
|
|
|
|
|
|
expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2');
|
|
|
|
|
|
expect(ep.primary_rationale.triggers_matched).toContain('ADR-011');
|
|
|
|
|
|
expect(ep.primary_rationale.triggers_matched.filter((t) => t === 'Pravila §12.2')).toHaveLength(1);
|
|
|
|
|
|
// candidates: tag contributes 'brain-retro' (no numbered list in text)
|
|
|
|
|
|
expect(ep.primary_rationale.candidates_considered).toContain('brain-retro');
|
|
|
|
|
|
// boundaries: heuristic empty (no marker in plain text), tag adds 'Pravila §16'
|
|
|
|
|
|
expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
import { extractAgentInvocations } from './observer-transcript-parser.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractAgentInvocations (Task 12)', () => {
|
|
|
|
|
|
it('emits subagent_invoked event from Agent tool_use', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', name: 'Agent',
|
|
|
|
|
|
input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } }
|
|
|
|
|
|
] } }];
|
|
|
|
|
|
const ev = extractAgentInvocations(turn);
|
|
|
|
|
|
expect(ev).toHaveLength(1);
|
|
|
|
|
|
expect(ev[0].kind).toBe('subagent_invoked');
|
|
|
|
|
|
expect(ev[0].subagent_type).toBe('general-purpose');
|
|
|
|
|
|
expect(ev[0].model).toBe('sonnet');
|
|
|
|
|
|
expect(ev[0].description).toBe('check files');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('uses "unknown" when subagent_type missing', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', name: 'Agent', input: {} }
|
|
|
|
|
|
] } }];
|
|
|
|
|
|
expect(extractAgentInvocations(turn)[0].subagent_type).toBe('unknown');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('truncates description at 80 chars', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', name: 'Agent',
|
|
|
|
|
|
input: { subagent_type: 'g', description: 'a'.repeat(200) } }
|
|
|
|
|
|
] } }];
|
|
|
|
|
|
expect(extractAgentInvocations(turn)[0].description.length).toBe(80);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty for non-Agent tool_use', () => {
|
|
|
|
|
|
const turn = [{ message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', name: 'Read', input: { file_path: '/a' } }
|
|
|
|
|
|
] } }];
|
|
|
|
|
|
expect(extractAgentInvocations(turn)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('safe on null/empty', () => {
|
|
|
|
|
|
expect(extractAgentInvocations(null)).toEqual([]);
|
|
|
|
|
|
expect(extractAgentInvocations([])).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — subagent_invoked events (Task 12)', () => {
|
|
|
|
|
|
it('emits subagent_invoked from Agent tool_use', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Agent',
|
|
|
|
|
|
input: { subagent_type: 'tester', model: 'haiku', description: 'verify spec' } }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
const subs = ep.events.filter((e) => e.kind === 'subagent_invoked');
|
|
|
|
|
|
expect(subs).toHaveLength(1);
|
|
|
|
|
|
expect(subs[0].subagent_type).toBe('tester');
|
|
|
|
|
|
expect(subs[0].model).toBe('haiku');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parallel_session — pre-flight OR clause (Task 13 PIVOT)', () => {
|
|
|
|
|
|
it('is true when Bash ran "git fetch && git log HEAD..origin/main"', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-flight' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch && git log HEAD..origin/main --oneline' } }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.environment.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('is true with origin/<other-branch> via HEAD..origin/feat', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'sync' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch origin && git log HEAD..origin/feat/x --oneline' } }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.environment.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('F1 still works — collision text in tool_result triggers', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -am ok' } }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
|
|
|
|
{ type: 'tool_result', tool_use_id: 't1', content: 'fatal: index.lock exists' }
|
|
|
|
|
|
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.environment.parallel_session).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('false on regular Bash without pre-flight or collision', () => {
|
|
|
|
|
|
const transcript = [
|
|
|
|
|
|
JSON.stringify({ sessionId: 's' }),
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'npm run test:tools' } }
|
|
|
|
|
|
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const ep = parseTranscript(transcript);
|
|
|
|
|
|
expect(ep.environment.parallel_session).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript v3 fields', () => {
|
|
|
|
|
|
function transcriptWithSkill(skillName) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'добавь endpoint /api/foo' },
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:00Z',
|
|
|
|
|
|
uuid: 'u-1',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{ type: 'tool_use', id: 't-1', name: 'Skill', input: { skill: skillName } },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:01Z',
|
|
|
|
|
|
uuid: 'u-2',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function transcriptDirectFeature() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'добавь новый endpoint /api/foo' },
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:00Z',
|
|
|
|
|
|
uuid: 'u-1',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:01Z',
|
|
|
|
|
|
uuid: 'u-2',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function transcriptWithHookAttachment() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'ls' },
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:00Z',
|
|
|
|
|
|
uuid: 'u-1',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: {
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: [{ type: 'tool_use', id: 't-1', name: 'Bash', input: { command: 'ls' } }],
|
|
|
|
|
|
},
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:01Z',
|
|
|
|
|
|
uuid: 'u-2',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'attachment',
|
|
|
|
|
|
attachment: { type: 'hook_success', hookName: 'PreToolUse:Bash', hookEvent: 'PreToolUse' },
|
|
|
|
|
|
timestamp: '2026-05-23T10:00:01Z',
|
|
|
|
|
|
uuid: 'u-3',
|
|
|
|
|
|
sessionId: 'sess-1',
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('emits schema_version: 4', () => {
|
|
|
|
|
|
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
|
|
|
|
|
|
expect(ep.schema_version).toBe(4);
|
|
|
|
|
|
expect(ep.schema_minor).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('recommended_node is null on direct episode without router-state (no classifier signal)', () => {
|
|
|
|
|
|
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
|
|
|
|
|
|
// Brain-retro #6 follow-up (2026-05-26): removed silent classification-map
|
|
|
|
|
|
// fallback. recommended_node now reflects ONLY real classifier signal from
|
|
|
|
|
|
// router-state. Direct feature episode without router-state → null, not '#19'.
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_node).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('recommended_node is null when a skill was invoked', () => {
|
|
|
|
|
|
const ep = parseTranscript(transcriptWithSkill('superpowers:writing-plans'), 'sess-1');
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_node).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('hook_fired event includes both counts and scripts keys', () => {
|
|
|
|
|
|
const ep = parseTranscript(transcriptWithHookAttachment(), 'sess-1');
|
|
|
|
|
|
const hookEvent = ep.events.find((e) => e.kind === 'hook_fired');
|
|
|
|
|
|
expect(hookEvent).toBeDefined();
|
|
|
|
|
|
expect(hookEvent.counts).toBeDefined();
|
|
|
|
|
|
expect(hookEvent.scripts).toBeDefined();
|
|
|
|
|
|
expect(typeof hookEvent.scripts).toBe('object');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — router-state enrichment (Task 3)', () => {
|
|
|
|
|
|
function makeTranscript(sessionId) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
message: { role: 'user', content: 'добавь новый endpoint /api/bar' },
|
|
|
|
|
|
timestamp: '2026-05-24T10:00:00Z',
|
|
|
|
|
|
uuid: 'u-t3-1',
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
}),
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'assistant',
|
|
|
|
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
|
|
|
|
|
|
timestamp: '2026-05-24T10:00:01Z',
|
|
|
|
|
|
uuid: 'u-t3-2',
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
}),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('enriches primary_rationale from router-state file when present', () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
|
|
|
|
|
const sessionId = 'test-session-t3-enrich';
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
classification: { recommendedNode: '#42', recommendedChain: 'L13' },
|
|
|
|
|
|
chainProgress: ['step-a', 'step-b'],
|
|
|
|
|
|
chainCompleted: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_node).toBe('#42');
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_chain).toBe('L13');
|
|
|
|
|
|
expect(ep.primary_rationale.chain_progress).toEqual(['step-a', 'step-b']);
|
|
|
|
|
|
expect(ep.primary_rationale.chain_completed).toBe(false);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('recommended_node is null when router-state file is absent (no silent regex fallback)', () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
|
|
|
|
|
const sessionId = 'test-session-t3-missing';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
|
|
|
|
|
|
// Brain-retro #6 follow-up (2026-05-26): no router-state → recommended_node = null.
|
|
|
|
|
|
// The earlier silent classification-map fallback baked false-positive recommendations
|
|
|
|
|
|
// into the JSONL — analysis showed 60-70% «recommended_node» values were just keyword
|
|
|
|
|
|
// regex from classifyTask(prompt), not real classifier output.
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_node).toBeNull();
|
|
|
|
|
|
expect(ep.primary_rationale.recommended_chain).toBeNull();
|
|
|
|
|
|
expect(ep.primary_rationale.chain_progress).toEqual([]);
|
|
|
|
|
|
expect(ep.primary_rationale.chain_completed).toBe(false);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('merges router-state.task_cost (classifier tokens) into episode task_cost — A1 cost tracking', () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
|
|
|
|
|
const sessionId = 'test-session-cost-merge';
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
classification: { recommendedNode: '#19' },
|
|
|
|
|
|
task_cost: {
|
|
|
|
|
|
classifier_input_tokens: 4500,
|
|
|
|
|
|
classifier_output_tokens: 120,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
|
|
|
|
|
|
expect(ep.task_cost.classifier_input_tokens).toBe(4500);
|
|
|
|
|
|
expect(ep.task_cost.classifier_output_tokens).toBe(120);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Phase 3 deferred #2: parser write-block v4.3 ────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred #2)', () => {
|
|
|
|
|
|
function simpleTranscript(prompt = 'add a feature', ts = '2026-05-25T10:00:00Z', sid = 's-v43') {
|
|
|
|
|
|
return [
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: prompt }, timestamp: ts, sessionId: sid }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, timestamp: ts, sessionId: sid }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('emits schema_minor 4', () => {
|
|
|
|
|
|
const ep = parseTranscript(simpleTranscript());
|
|
|
|
|
|
expect(ep.schema_minor).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits outcome_reviewed: null', () => {
|
|
|
|
|
|
const ep = parseTranscript(simpleTranscript());
|
|
|
|
|
|
expect('outcome_reviewed' in ep).toBe(true);
|
|
|
|
|
|
expect(ep.outcome_reviewed).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits outcome_reviewed_source: null', () => {
|
|
|
|
|
|
const ep = parseTranscript(simpleTranscript());
|
|
|
|
|
|
expect('outcome_reviewed_source' in ep).toBe(true);
|
|
|
|
|
|
expect(ep.outcome_reviewed_source).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('emits prompt_embedding_base64 as null when embedding model unavailable', () => {
|
|
|
|
|
|
// parser is synchronous; embedding is null by design (filled async by stop-hook)
|
|
|
|
|
|
const ep = parseTranscript(simpleTranscript());
|
|
|
|
|
|
expect('prompt_embedding_base64' in ep).toBe(true);
|
|
|
|
|
|
expect(ep.prompt_embedding_base64).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does not throw when transcript has unusual content', () => {
|
|
|
|
|
|
// robustness guard: parser must never throw regardless of transcript shape
|
|
|
|
|
|
expect(() => parseTranscript(simpleTranscript('', '2026-05-25T10:00:00Z'))).not.toThrow();
|
|
|
|
|
|
expect(() => parseTranscript('')).not.toThrow();
|
|
|
|
|
|
expect(() => parseTranscript('{ broken json\nnot valid')).not.toThrow();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('task_cost has 8 new zero-default LLM-cost fields', () => {
|
|
|
|
|
|
const ep = parseTranscript(simpleTranscript());
|
|
|
|
|
|
const cost = ep.task_cost;
|
|
|
|
|
|
expect(typeof cost.classifier_input_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.classifier_output_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.self_assessment_input_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.self_assessment_output_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.reviewer_input_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.reviewer_output_tokens).toBe('number');
|
|
|
|
|
|
expect(typeof cost.reviewer_subagent_usd).toBe('number');
|
|
|
|
|
|
expect(typeof cost.reviewer_direct_fallback_usd).toBe('number');
|
|
|
|
|
|
// all default to 0
|
|
|
|
|
|
expect(cost.classifier_input_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.classifier_output_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.self_assessment_input_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.self_assessment_output_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.reviewer_input_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.reviewer_output_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.reviewer_subagent_usd).toBe(0);
|
|
|
|
|
|
expect(cost.reviewer_direct_fallback_usd).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('task_cost retains all existing fields alongside new ones', () => {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'do it' } }),
|
|
|
|
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 50 } } }),
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
const cost = parseTranscript(lines).task_cost;
|
|
|
|
|
|
expect(cost.input_tokens).toBe(100);
|
|
|
|
|
|
expect(cost.output_tokens).toBe(20);
|
|
|
|
|
|
expect(cost.cache_read_input_tokens).toBe(500);
|
|
|
|
|
|
expect(cost.cache_creation_input_tokens).toBe(50);
|
|
|
|
|
|
// new fields still 0 (populated retroactively by controller scripts)
|
|
|
|
|
|
expect(cost.classifier_input_tokens).toBe(0);
|
|
|
|
|
|
expect(cost.reviewer_subagent_usd).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-factor-analysis-4passes)', () => {
|
|
|
|
|
|
it('classifies test files', () => {
|
|
|
|
|
|
expect(classifyFilePath('tools/foo.test.mjs')).toBe('test');
|
|
|
|
|
|
expect(classifyFilePath('app/tests/Feature/X.php')).toBe('test');
|
|
|
|
|
|
expect(classifyFilePath('resources/js/foo.spec.ts')).toBe('test');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies config files', () => {
|
|
|
|
|
|
expect(classifyFilePath('package.json')).toBe('config');
|
|
|
|
|
|
expect(classifyFilePath('vite.config.ts')).toBe('config');
|
|
|
|
|
|
expect(classifyFilePath('lefthook.yml')).toBe('config');
|
|
|
|
|
|
expect(classifyFilePath('.env')).toBe('config');
|
|
|
|
|
|
expect(classifyFilePath('tsconfig.json')).toBe('config');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies spec/plan files under docs/superpowers/', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/superpowers/specs/x.md')).toBe('spec');
|
|
|
|
|
|
expect(classifyFilePath('docs/superpowers/plans/x.md')).toBe('spec');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies normative documents', () => {
|
|
|
|
|
|
expect(classifyFilePath('CLAUDE.md')).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md')).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md')).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('docs/Tooling_v8_3.md')).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md')).toBe('norm');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies data files', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/observer/episodes-2026-05.jsonl')).toBe('data');
|
|
|
|
|
|
expect(classifyFilePath('db/seed.csv')).toBe('data');
|
|
|
|
|
|
expect(classifyFilePath('db/schema.sql')).toBe('data');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('classifies app/tools source under src', () => {
|
|
|
|
|
|
expect(classifyFilePath('app/Http/Controllers/X.php')).toBe('src');
|
|
|
|
|
|
expect(classifyFilePath('tools/router-classifier.mjs')).toBe('src');
|
|
|
|
|
|
expect(classifyFilePath('resources/js/views/Dashboard.vue')).toBe('src');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns other for paths that fit no category', () => {
|
|
|
|
|
|
expect(classifyFilePath('some-random-binary.png')).toBe('other');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-16 12:23:40 +03:00
|
|
|
|
describe('classifyFilePath — config-driven normative stems (greenfield #3-observer)', () => {
|
|
|
|
|
|
it('custom stem matches a greenfield normative doc', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/MyRules_v2.md', ['MyRules'])).toBe('norm');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('custom stems excluding Лидерра docs → those are not norm', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md', ['MyRules'])).toBe('other');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('empty stems → Лидерра doc not norm, universal CLAUDE.md/memory still norm', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/Tooling_v8_3.md', [])).toBe('other');
|
|
|
|
|
|
expect(classifyFilePath('CLAUDE.md', [])).toBe('norm');
|
|
|
|
|
|
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\p\\memory\\f.md', [])).toBe('norm');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('default (no stems arg) keeps current 3 Лидерра stems as norm', () => {
|
|
|
|
|
|
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
|
|
|
|
|
|
});
|
|
|
|
|
|
it('extractFileTypeDistribution threads custom normativeStems', () => {
|
|
|
|
|
|
expect(extractFileTypeDistribution(['docs/MyRules_v2.md'], ['MyRules']).norm).toBe(1);
|
|
|
|
|
|
expect(extractFileTypeDistribution(['docs/MyRules_v2.md'], []).norm).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-15 08:06:08 +03:00
|
|
|
|
describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => {
|
|
|
|
|
|
it('counts each path bucket and zero-fills missing categories', () => {
|
|
|
|
|
|
const dist = extractFileTypeDistribution([
|
|
|
|
|
|
'tools/router-classifier.mjs',
|
|
|
|
|
|
'tools/router-classifier.test.mjs',
|
|
|
|
|
|
'docs/superpowers/specs/x.md',
|
|
|
|
|
|
'CLAUDE.md',
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(dist.src).toBe(1);
|
|
|
|
|
|
expect(dist.test).toBe(1);
|
|
|
|
|
|
expect(dist.spec).toBe(1);
|
|
|
|
|
|
expect(dist.norm).toBe(1);
|
|
|
|
|
|
expect(dist.config).toBe(0);
|
|
|
|
|
|
expect(dist.data).toBe(0);
|
|
|
|
|
|
expect(dist.other).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns all-zero distribution for empty input', () => {
|
|
|
|
|
|
const dist = extractFileTypeDistribution([]);
|
|
|
|
|
|
for (const v of Object.values(dist)) expect(v).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('extractMcpServers — Pass 3 (project-brain-factor-analysis-4passes)', () => {
|
|
|
|
|
|
it('extracts unique mcp__<server>__* prefixes from tool_use entries', () => {
|
|
|
|
|
|
const turn = [
|
|
|
|
|
|
assistantTurn([
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'mcp__github__list_issues', input: {} },
|
|
|
|
|
|
{ type: 'tool_use', id: 't2', name: 'mcp__github__get_pr', input: {} },
|
|
|
|
|
|
{ type: 'tool_use', id: 't3', name: 'mcp__playwright__browser_click', input: {} },
|
|
|
|
|
|
{ type: 'tool_use', id: 't4', name: 'Read', input: { file_path: 'a.txt' } },
|
|
|
|
|
|
], '2026-05-25T10:00:00Z'),
|
|
|
|
|
|
];
|
|
|
|
|
|
expect(extractMcpServers(turn).sort()).toEqual(['github', 'playwright']);
|
|
|
|
|
|
});
|
|
|
|
|
|
it('returns empty array when no mcp tools used', () => {
|
|
|
|
|
|
const turn = [assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a' } }], '2026-05-25T10:00:00Z')];
|
|
|
|
|
|
expect(extractMcpServers(turn)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('parseTranscript — Pass 3 task_meta block (project-brain-factor-analysis-4passes)', () => {
|
|
|
|
|
|
it('includes prompt_length_chars / mcp_servers_used / file_type_distribution under task_meta', () => {
|
|
|
|
|
|
const t = jsonl([
|
|
|
|
|
|
userPrompt('добавь функцию X в файл tools/router-classifier.mjs', '2026-05-25T10:00:00Z'),
|
|
|
|
|
|
assistantTurn([
|
|
|
|
|
|
{ type: 'tool_use', id: 't1', name: 'mcp__github__get_pr', input: {} },
|
|
|
|
|
|
{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: 'tools/router-classifier.mjs' } },
|
|
|
|
|
|
{ type: 'tool_use', id: 't3', name: 'Edit', input: { file_path: 'tools/router-classifier.test.mjs', old_string: 'a', new_string: 'b' } },
|
|
|
|
|
|
], '2026-05-25T10:01:00Z'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const ep = parseTranscript(t);
|
|
|
|
|
|
expect(ep.task_meta).toBeDefined();
|
|
|
|
|
|
expect(ep.task_meta.prompt_length_chars).toBe('добавь функцию X в файл tools/router-classifier.mjs'.length);
|
|
|
|
|
|
expect(ep.task_meta.mcp_servers_used).toEqual(['github']);
|
|
|
|
|
|
expect(ep.task_meta.file_type_distribution.src).toBe(1);
|
|
|
|
|
|
expect(ep.task_meta.file_type_distribution.test).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('task_meta is present even on empty transcript (null-safe defaults)', () => {
|
|
|
|
|
|
const ep = parseTranscript('');
|
|
|
|
|
|
expect(ep.task_meta).toBeDefined();
|
|
|
|
|
|
expect(ep.task_meta.prompt_length_chars).toBe(0);
|
|
|
|
|
|
expect(ep.task_meta.mcp_servers_used).toEqual([]);
|
|
|
|
|
|
expect(ep.task_meta.file_type_distribution.other).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|