47c03a9e18
Closes brain-retro 2026-05-20 #1 — analysis/memory-sync/regulatory-bump/ release/cleanup/monitoring/planning. Addresses '59% other' observation from initial retro factor matrix. Ordering: release before feature (merge feature-branch), planning before refactor (план рефакторинга), memory-sync/regulatory-bump at top as most specific. monitoring regex проверь состоян covers inflected forms. 9 new vitest tests, 241/241 GREEN in npm run test:tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
923 lines
36 KiB
JavaScript
923 lines
36 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
parseTranscript,
|
||
extractEnvironment,
|
||
extractTaskSize,
|
||
classifyPromptSignal,
|
||
extractProcessEvents,
|
||
parseRoutingTag,
|
||
extractLastUserPromptText,
|
||
classifyTask,
|
||
} from './observer-transcript-parser.mjs';
|
||
|
||
// Build a JSONL transcript string from entry objects.
|
||
function jsonl(entries) {
|
||
return entries.map((e) => JSON.stringify(e)).join('\n');
|
||
}
|
||
|
||
function userPrompt(text, ts, sessionId = 's1') {
|
||
return { type: 'user', message: { role: 'user', content: text }, timestamp: ts, sessionId };
|
||
}
|
||
function assistantTurn(blocks, ts, sessionId = 's1') {
|
||
return { type: 'assistant', message: { role: 'assistant', content: blocks }, timestamp: ts, sessionId };
|
||
}
|
||
function toolResult(toolUseId, isError, ts, sessionId = 's1') {
|
||
return {
|
||
type: 'user',
|
||
message: {
|
||
role: 'user',
|
||
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'r', is_error: isError }],
|
||
},
|
||
timestamp: ts,
|
||
sessionId,
|
||
};
|
||
}
|
||
|
||
describe('parseTranscript', () => {
|
||
it('extracts task_id from sessionId in transcript', () => {
|
||
const t = jsonl([userPrompt('add a feature', '2026-05-19T10:00:00Z', 'sess-xyz')]);
|
||
expect(parseTranscript(t).task_id).toBe('sess-xyz');
|
||
});
|
||
|
||
it('falls back to provided sessionId when transcript has none', () => {
|
||
const t = jsonl([
|
||
{ type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z' },
|
||
]);
|
||
expect(parseTranscript(t, 'fallback-id').task_id).toBe('fallback-id');
|
||
});
|
||
|
||
it('extracts timestamps from the last turn', () => {
|
||
const t = jsonl([
|
||
userPrompt('first', '2026-05-19T09:00:00Z'),
|
||
userPrompt('second turn', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'text', text: 'done' }], '2026-05-19T10:05:00Z'),
|
||
]);
|
||
const ep = parseTranscript(t);
|
||
expect(ep.timestamps.started_at).toBe('2026-05-19T10:00:00Z');
|
||
expect(ep.timestamps.ended_at).toBe('2026-05-19T10:05:00Z');
|
||
});
|
||
|
||
it('emits skill_invoked events for Skill tool_use', () => {
|
||
const t = jsonl([
|
||
userPrompt('do tdd', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
const skillEvents = parseTranscript(t).events.filter((e) => e.kind === 'skill_invoked');
|
||
expect(skillEvents).toHaveLength(1);
|
||
expect(skillEvents[0].skill).toBe('superpowers:test-driven-development');
|
||
});
|
||
|
||
it('emits a tool_summary event with per-tool counts', () => {
|
||
const t = jsonl([
|
||
userPrompt('work', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[
|
||
{ type: 'tool_use', id: 't1', name: 'Read', input: {} },
|
||
{ type: 'tool_use', id: 't2', name: 'Read', input: {} },
|
||
{ type: 'tool_use', id: 't3', name: 'Bash', input: {} },
|
||
],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
|
||
expect(summary.counts).toEqual({ Read: 2, Bash: 1 });
|
||
});
|
||
|
||
it('node_chosen is the first skill invoked', () => {
|
||
const t = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('superpowers:brainstorming');
|
||
});
|
||
|
||
it('node_chosen is "direct" when no skill invoked', () => {
|
||
const t = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
||
]);
|
||
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct');
|
||
});
|
||
|
||
it('hard_floor invoked when a superpowers skill is used', () => {
|
||
const t = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:writing-plans' } }],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
const hf = parseTranscript(t).primary_rationale.hard_floor;
|
||
expect(hf.invoked).toBe(true);
|
||
expect(hf.rules).toContain('Pravila §12');
|
||
});
|
||
|
||
it('hard_floor not invoked for a non-superpowers skill', () => {
|
||
const t = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'claude-md-management:claude-md-improver' } }],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
expect(parseTranscript(t).primary_rationale.hard_floor.invoked).toBe(false);
|
||
});
|
||
|
||
it('classifies task from the user prompt text', () => {
|
||
const cls = (text) =>
|
||
parseTranscript(jsonl([userPrompt(text, '2026-05-19T10:00:00Z')])).primary_rationale.task_classification;
|
||
expect(cls('почини баг в логине')).toBe('bugfix');
|
||
expect(cls('добавь новую фичу экспорта')).toBe('feature');
|
||
expect(cls('отрефактори этот модуль')).toBe('refactor');
|
||
expect(cls('как это работает?')).toBe('question');
|
||
expect(cls('обнови документацию readme')).toBe('docs');
|
||
});
|
||
|
||
it('scopes to the last turn — earlier turns are excluded', () => {
|
||
const t = jsonl([
|
||
userPrompt('turn 1', '2026-05-19T09:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
|
||
'2026-05-19T09:01:00Z'
|
||
),
|
||
userPrompt('turn 2', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
||
]);
|
||
const ep = parseTranscript(t);
|
||
expect(ep.events.filter((e) => e.kind === 'skill_invoked')).toHaveLength(0);
|
||
expect(ep.primary_rationale.node_chosen).toBe('direct');
|
||
});
|
||
|
||
it('does not treat a tool_result message as a turn boundary', () => {
|
||
const t = jsonl([
|
||
userPrompt('the only real prompt', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
|
||
toolResult('t1', false, '2026-05-19T10:02:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Edit', input: {} }], '2026-05-19T10:03:00Z'),
|
||
]);
|
||
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
|
||
expect(summary.counts).toEqual({ Bash: 1, Edit: 1 });
|
||
});
|
||
|
||
it('emits error events for tool_result with is_error', () => {
|
||
const t = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
|
||
toolResult('t1', true, '2026-05-19T10:02:00Z'),
|
||
]);
|
||
expect(parseTranscript(t).events.filter((e) => e.kind === 'error')).toHaveLength(1);
|
||
});
|
||
|
||
it('skips broken JSONL lines without throwing', () => {
|
||
const t = [
|
||
JSON.stringify(userPrompt('go', '2026-05-19T10:00:00Z')),
|
||
'{ broken json',
|
||
JSON.stringify(assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z')),
|
||
].join('\n');
|
||
expect(() => parseTranscript(t)).not.toThrow();
|
||
expect(parseTranscript(t).events.find((e) => e.kind === 'tool_summary').counts).toEqual({ Read: 1 });
|
||
});
|
||
|
||
it('path_type is regulated with a superpowers skill, improvised otherwise', () => {
|
||
const reg = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn(
|
||
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }],
|
||
'2026-05-19T10:01:00Z'
|
||
),
|
||
]);
|
||
const imp = jsonl([
|
||
userPrompt('go', '2026-05-19T10:00:00Z'),
|
||
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
|
||
]);
|
||
expect(parseTranscript(reg).path_type).toBe('regulated');
|
||
expect(parseTranscript(imp).path_type).toBe('improvised');
|
||
});
|
||
|
||
it('returns safe defaults for an empty transcript', () => {
|
||
const ep = parseTranscript('');
|
||
expect(ep.task_id).toBeTruthy();
|
||
expect(ep.primary_rationale.node_chosen).toBe('direct');
|
||
expect(ep.events).toEqual([]);
|
||
expect(ep.outcome).toBe('unknown');
|
||
expect(ep.schema_version).toBe(2);
|
||
});
|
||
|
||
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 — v2 episode', () => {
|
||
it('produces schema_version 2 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(2);
|
||
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('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');
|
||
});
|
||
});
|