7b4da1477e
Brain-retro #6 follow-up #2 (consolidated). Eight independent fixes: A1 — task_cost wiring (cost tracking) - router-prehook.mjs: capture classifier LLM usage via onUsage callback, persist to state.task_cost.classifier_input_tokens / output_tokens. - observer-transcript-parser.mjs: merge router-state.task_cost on top of extractTokenUsage(turn). State-file values win for classifier/ self_assessment/reviewer fields. - New buildCostFromClassifierUsage() exported from router-prehook. - Verified live: state file now shows real input_tokens=190 / output_tokens=598 / cache_read=10075 (was 0 before). A2 — self-assessment coverage - observer-self-assessment-api.mjs: DEFAULT_TIMEOUT_MS 10s -> 30s. - .claude/settings.json: Stop-hook timeout 15s -> 60s. - Same Windows TLS handshake issue. Was 85% no_self_assessment in retro #6. B3 — brain-retro SKILL.md reconciliation - Step 5b: batch=default for N>=20, subagent for N<20. C1 — dead-code cleanup - Removed recommendNode import + getClassificationMap + getDormancy from observer-transcript-parser.mjs. G — parseClassifierResponse Pass 3 (fixLLMJsonQuirks) - Root cause: real Sonnet output sometimes contains raw newlines inside string values (multi-line reason_for_choice) and trailing commas, which strict JSON.parse rejects. Result was llm_error_type=parse_null on every other call, falling back to regex with task_type=unknown. - Fix: after Pass 1 (clean) and Pass 2 (brace-extract) fail, try Pass 3 that escapes raw newline/tab inside string values and strips trailing commas before final JSON.parse attempt. Pure char-walk, no JSON5 dep. H — 'unknown' added to NON_BLOCKING_TASK_TYPES in router-tool-gate.mjs - Until G fully proves itself, blocking Bash/Edit on unknown is too strict. With G in place, parse_null should be rare; H gives a safety net. Tests added: +9 across 5 test files. Regression: 913 vitest tests in tools/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
4.5 KiB
JavaScript
111 lines
4.5 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { buildStateFromClassification } from './router-prehook.mjs';
|
|
|
|
describe('buildStateFromClassification — Phase 2 Task 14', () => {
|
|
it('builds full state object (v4 shape: task_id + task_cost, no enforcementRequired)', () => {
|
|
const cls = { task_type: 'feature', recommended_node: '#19', source: 'llm' };
|
|
const s = buildStateFromClassification(cls, { sessionId: 'abc', promptHash: '12345' });
|
|
expect(s.sessionId).toBe('abc');
|
|
expect(s.promptHash).toBe('12345');
|
|
expect(s.classification).toEqual(cls);
|
|
expect(s.skillInvokedThisTurn).toBe(false);
|
|
expect(s.chainProgress).toEqual([]);
|
|
expect(s.timestamp).toBeDefined();
|
|
expect(typeof s.task_id).toBe('string');
|
|
expect(s.task_cost).toEqual({});
|
|
expect(s.enforcementRequired).toBeUndefined();
|
|
});
|
|
|
|
it('emits a fresh task_id per call (random)', () => {
|
|
const cls = { task_type: 'feature' };
|
|
const a = buildStateFromClassification(cls, { sessionId: 's', promptHash: 'h' });
|
|
const b = buildStateFromClassification(cls, { sessionId: 's', promptHash: 'h' });
|
|
expect(a.task_id).not.toBe(b.task_id);
|
|
});
|
|
|
|
it('honors externally supplied taskId (caller wants determinism)', () => {
|
|
const s = buildStateFromClassification(
|
|
{ task_type: 'feature' },
|
|
{ sessionId: 's', promptHash: 'h', taskId: 'pinned-1' },
|
|
);
|
|
expect(s.task_id).toBe('pinned-1');
|
|
});
|
|
|
|
it('writes inheritance block on continuation (B5)', () => {
|
|
const s = buildStateFromClassification(
|
|
{ task_type: 'feature', source: 'prefilter_inherited' },
|
|
{ sessionId: 's', promptHash: 'h', inheritedFrom: 'prev', ageMin: 5 },
|
|
);
|
|
expect(s.inheritance.inherited_from_task_id).toBe('prev');
|
|
expect(s.inheritance.inheritance_age_minutes).toBe(5);
|
|
});
|
|
|
|
it('omits inheritance block when not a continuation', () => {
|
|
const s = buildStateFromClassification(
|
|
{ task_type: 'feature', source: 'llm' },
|
|
{ sessionId: 's', promptHash: 'h' },
|
|
);
|
|
expect(s.inheritance).toBeUndefined();
|
|
});
|
|
|
|
it('threads cost block through when caller provides it', () => {
|
|
const s = buildStateFromClassification(
|
|
{ task_type: 'feature' },
|
|
{ sessionId: 's', promptHash: 'h', cost: { classifier_input_tokens: 1234, classifier_output_tokens: 200 } },
|
|
);
|
|
expect(s.task_cost.classifier_input_tokens).toBe(1234);
|
|
expect(s.task_cost.classifier_output_tokens).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('buildCostFromClassifierUsage — A1 cost tracking (2026-05-26)', () => {
|
|
it('builds classifier cost block from Anthropic API usage shape', async () => {
|
|
const { buildCostFromClassifierUsage } = await import('./router-prehook.mjs');
|
|
const usage = { input_tokens: 5000, output_tokens: 120 };
|
|
const cost = buildCostFromClassifierUsage(usage);
|
|
expect(cost.classifier_input_tokens).toBe(5000);
|
|
expect(cost.classifier_output_tokens).toBe(120);
|
|
});
|
|
|
|
it('honors cache_read / cache_creation tokens (Anthropic prompt caching)', async () => {
|
|
const { buildCostFromClassifierUsage } = await import('./router-prehook.mjs');
|
|
const usage = {
|
|
input_tokens: 100,
|
|
output_tokens: 50,
|
|
cache_read_input_tokens: 4500,
|
|
cache_creation_input_tokens: 500,
|
|
};
|
|
const cost = buildCostFromClassifierUsage(usage);
|
|
expect(cost.classifier_input_tokens).toBe(100);
|
|
expect(cost.classifier_output_tokens).toBe(50);
|
|
expect(cost.classifier_cache_read_input_tokens).toBe(4500);
|
|
expect(cost.classifier_cache_creation_input_tokens).toBe(500);
|
|
});
|
|
|
|
it('returns empty object on null/undefined usage', async () => {
|
|
const { buildCostFromClassifierUsage } = await import('./router-prehook.mjs');
|
|
expect(buildCostFromClassifierUsage(null)).toEqual({});
|
|
expect(buildCostFromClassifierUsage(undefined)).toEqual({});
|
|
expect(buildCostFromClassifierUsage({})).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('ENFORCEMENT_TYPES legacy export removed (D1 closure)', () => {
|
|
it('does not export ENFORCEMENT_TYPES', async () => {
|
|
const mod = await import('./router-prehook.mjs');
|
|
expect(mod.ENFORCEMENT_TYPES).toBeUndefined();
|
|
});
|
|
|
|
it('does not export isEnforcementRequired', async () => {
|
|
const mod = await import('./router-prehook.mjs');
|
|
expect(mod.isEnforcementRequired).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
|
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
|
const mod = await import('./router-prehook.mjs');
|
|
expect(typeof mod.buildStateFromClassification).toBe('function');
|
|
});
|
|
});
|