81cbd8c1c2
retro #7 (docs/observer/notes/2026-05-27-brain-retro-7.md) surfaced 4 candidates against 23 turns since retro #6. All four implemented TDD. C1 — translit slang vocabulary in router-classifier-regex-fallback.mjs. TASK_TYPE_KEYWORDS += deploy bucket (push / запушь / выкат); memory-sync += обнови мозг / эталон / пилот / memory dump. C2 — short_ambiguous_block in router-tool-gate.mjs + router-prehook.mjs. prehook persists prompt_length; gate blocks Edit/Write/MultiEdit/Bash when task_type in {ambiguous, unknown} AND prompt_length <= 30 AND skill not invoked AND no direct_justified tag. C3 — self-assessment timeout 30s to 50s in observer-self-assessment-api.mjs. Windows TLS handshake + Sonnet latency exceeded 30s. Stop-hook has 60s budget; 50s leaves headroom. DEFAULT_TIMEOUT_MS exported for tests. C4 — Reviewer findings block in status-md-generator.mjs. New helper computeReviewerFindingsBlock surfaces 51 actionable findings without running /brain-retro. Detects batch-reviewed via outcome_reviewed_source=direct_api_batch. MD012 guard test added. C5 (gitleaks-before-push) intentionally skipped — pre-push hook already blocks at server side. Tests: 956/956 root tools, 0 regressions. LEFTHOOK=0 used per quirk #111. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
471 lines
22 KiB
JavaScript
471 lines
22 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { renderStatus, computeCostBlock, computeAnomalyBlock, computeSelfRetrospectBlock, computeReviewerBlock, computeReviewerFindingsBlock, computeSessionLengthBlock } from './status-md-generator.mjs';
|
|
|
|
const baseInputs = (overrides = {}) => ({
|
|
now: '2026-05-19T10:00:00+03:00',
|
|
c1: { status: 'ok', detail: 'no drift' },
|
|
c2: { status: 'ok', detail: '0 version drift' },
|
|
c3: { status: 'ok', detail: 'last read today' },
|
|
c5: { status: 'ok', detail: 'coverage OK · registration OK' },
|
|
c6: { status: 'ok', detail: '14 chains in sync' },
|
|
observer: { episodeCount: 12, observerErrors: 0, piiMatches: 0 },
|
|
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
|
...overrides,
|
|
});
|
|
|
|
describe('renderStatus', () => {
|
|
it('renders all 5 controllers + metrics', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toContain('# Brain Status');
|
|
expect(md).toContain('| C1 L1-watcher | ✅');
|
|
expect(md).toContain('| C2 Cross-ref consistency | ✅');
|
|
expect(md).toContain('| C3 Observer-of-observer | ✅');
|
|
expect(md).toContain('| C4 Сигнальный статус | ✅');
|
|
expect(md).toContain('| C5 Observer-coverage | ✅');
|
|
expect(md).toContain('12 episodes');
|
|
});
|
|
|
|
it('includes a C6 chain-map row', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toContain('| C6 Chain map sync | ✅');
|
|
});
|
|
|
|
it('shows a warn status for the coverage controller', () => {
|
|
const md = renderStatus(baseInputs({ c5: { status: 'warn', detail: '3 commits, 0 episodes' } }));
|
|
expect(md).toContain('| C5 Observer-coverage | ⚠️');
|
|
});
|
|
|
|
it('shows the observer_error count in the metrics block', () => {
|
|
const md = renderStatus(baseInputs({ observer: { episodeCount: 4, observerErrors: 2, piiMatches: 0 } }));
|
|
expect(md).toContain('2 observer_error markers');
|
|
});
|
|
|
|
it('shows a red status for failing controllers', () => {
|
|
const md = renderStatus(baseInputs({ c1: { status: 'fail', detail: '2 plugins not formalized' } }));
|
|
expect(md).toContain('| C1 L1-watcher | 🔴');
|
|
});
|
|
|
|
it('mentions the conditional capability-readiness behavioral rule (§16.4 v1.36)', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toContain('Неиспользованные узлы — не алерт');
|
|
expect(md).toContain('если профильной задачи не было');
|
|
expect(md).toContain('feedback_brain_unused_tools_not_problem');
|
|
});
|
|
|
|
it('shows piiMatches > 0 when counter file has data (Task 3)', () => {
|
|
const md = renderStatus(baseInputs({ observer: { episodeCount: 24, observerErrors: 0, piiMatches: 7 } }));
|
|
expect(md).toMatch(/7 PII matches before filter/);
|
|
});
|
|
});
|
|
|
|
describe('renderStatus — last /brain-retro (Task 10)', () => {
|
|
it('shows last /brain-retro days-ago when counter has data', () => {
|
|
const md = renderStatus(baseInputs({ lastRetroDaysAgo: 3 }));
|
|
expect(md).toMatch(/Last \/brain-retro:\s*3 day\(s\) ago/);
|
|
});
|
|
it('shows "never" when lastRetroDaysAgo is null', () => {
|
|
const md = renderStatus(baseInputs({ lastRetroDaysAgo: null }));
|
|
expect(md).toMatch(/Last \/brain-retro:\s*never/);
|
|
});
|
|
it('shows "never" when lastRetroDaysAgo is undefined', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toMatch(/Last \/brain-retro:\s*never/);
|
|
});
|
|
});
|
|
|
|
describe('renderStatus — v1 episodes count surface (Task 18)', () => {
|
|
it('shows v1 count when present', () => {
|
|
const md = renderStatus(baseInputs({ observer: { episodeCount: 22, observerErrors: 0, piiMatches: 0, v1Episodes: 5 } }));
|
|
expect(md).toMatch(/Legacy v1 episodes \(not in factor analysis\):\s*5/);
|
|
});
|
|
it('shows 0 when v1Episodes undefined', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toMatch(/Legacy v1 episodes \(not in factor analysis\):\s*0/);
|
|
});
|
|
});
|
|
|
|
describe('renderStatus — missed activations (Task 7, Pravila §16.4 v1.36)', () => {
|
|
it('renders missed_activations: 0 when there are no misses', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toContain('missed_activations: 0');
|
|
});
|
|
|
|
it('renders missed_activations: N when misses occur', () => {
|
|
const md = renderStatus(baseInputs({
|
|
missed: { totalMissed: 3, byNode: { '#11': 2, '#12': 1 }, byClassification: { refactor: 3 } },
|
|
}));
|
|
expect(md).toContain('missed_activations: 3');
|
|
});
|
|
|
|
it('keeps C5 ✅ when controller is ok and no misses', () => {
|
|
const md = renderStatus(baseInputs());
|
|
expect(md).toContain('| C5 Observer-coverage | ✅');
|
|
});
|
|
|
|
it('honors the c5 status override (warn) regardless of missed count', () => {
|
|
const md = renderStatus(baseInputs({
|
|
c5: { status: 'warn', detail: '16 missed activation(s)' },
|
|
}));
|
|
expect(md).toContain('| C5 Observer-coverage | ⚠️');
|
|
});
|
|
});
|
|
|
|
describe('renderStatus — discipline block (stage 2)', () => {
|
|
const baseInputs = {
|
|
now: '2026-05-24T10:00:00Z',
|
|
c1: { status: 'ok', detail: 'OK' },
|
|
c2: { status: 'ok', detail: 'OK' },
|
|
c3: { status: 'ok', detail: 'OK' },
|
|
c5: { status: 'ok', detail: 'OK' },
|
|
c6: { status: 'ok', detail: 'OK' },
|
|
observer: { episodeCount: 10, observerErrors: 0, piiMatches: 0, v1Episodes: 0 },
|
|
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
|
lastRetroDaysAgo: 0,
|
|
};
|
|
|
|
it('renders discipline table when discipline data is provided', () => {
|
|
const md = renderStatus({
|
|
...baseInputs,
|
|
discipline: {
|
|
byClassification: {
|
|
feature: { episodes: 5, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 },
|
|
bugfix: { episodes: 6, withTriggerMatch: 2, viaSkill: 2, pctTriggerMatch: 0.333, pctViaSkill: 0.333 },
|
|
},
|
|
routerStep: { distribution: { '1': 10, '3': 1 }, total: 11, suspicious: true },
|
|
boundariesRate: { total: 11, withBoundaries: 3, rate: 0.273, byPathType: {} },
|
|
},
|
|
});
|
|
expect(md).toMatch(/## Метрики дисциплины/);
|
|
expect(md).toMatch(/feature/);
|
|
expect(md).toMatch(/bugfix/);
|
|
expect(md).toMatch(/33\.3%/);
|
|
expect(md).toMatch(/router step distribution/i);
|
|
expect(md).toMatch(/⚠️.*suspicious/i);
|
|
expect(md).toMatch(/boundaries applied/i);
|
|
expect(md).toMatch(/27\.3%/);
|
|
});
|
|
|
|
it('omits the discipline block when discipline is absent (backward compat)', () => {
|
|
const md = renderStatus(baseInputs);
|
|
expect(md).not.toMatch(/## Метрики дисциплины/);
|
|
});
|
|
|
|
it('coexists: both sessionLengthBlock (brain-retro candidate B) and overrideUsageBlock (enforce hole 8) appear together in template after merge', () => {
|
|
const md = renderStatus({
|
|
...baseInputs,
|
|
sessionLengthBlock: '## Длинные сессии\n\nflagged content',
|
|
overrideUsageBlock: '## Использование override-фраз\n\nflagged content',
|
|
});
|
|
expect(md).toContain('## Длинные сессии');
|
|
expect(md).toContain('## Использование override-фраз');
|
|
});
|
|
});
|
|
|
|
// ── Phase 3 deferred #3: 4 new helper blocks ─────────────────────────────────
|
|
|
|
const PRICING_TEST = {
|
|
sonnet46: { input_per_mtok: 3.0, output_per_mtok: 15.0 },
|
|
opus47: { input_per_mtok: 15.0, output_per_mtok: 75.0 },
|
|
};
|
|
|
|
const makeEp = (overrides = {}) => ({
|
|
schema_version: 2,
|
|
task_cost: {
|
|
classifier_input_tokens: 0,
|
|
classifier_output_tokens: 0,
|
|
self_assessment_input_tokens: 0,
|
|
self_assessment_output_tokens: 0,
|
|
reviewer_subagent_usd: null,
|
|
reviewer_input_tokens: 0,
|
|
reviewer_output_tokens: 0,
|
|
reviewer_direct_fallback_usd: null,
|
|
},
|
|
review: { reviewed_at: null, reviewer: null, reviewer_error: false },
|
|
...overrides,
|
|
});
|
|
|
|
describe('computeCostBlock', () => {
|
|
it('sums token costs and formats USD for 3 episodes', () => {
|
|
const eps = [
|
|
makeEp({ task_cost: { classifier_input_tokens: 1000, classifier_output_tokens: 200, self_assessment_input_tokens: 500, self_assessment_output_tokens: 100, reviewer_subagent_usd: null, reviewer_input_tokens: 0, reviewer_output_tokens: 0, reviewer_direct_fallback_usd: null } }),
|
|
makeEp({ task_cost: { classifier_input_tokens: 2000, classifier_output_tokens: 400, self_assessment_input_tokens: 1000, self_assessment_output_tokens: 200, reviewer_subagent_usd: 0.01, reviewer_input_tokens: 500, reviewer_output_tokens: 100, reviewer_direct_fallback_usd: null } }),
|
|
makeEp({ task_cost: { classifier_input_tokens: 500, classifier_output_tokens: 100, self_assessment_input_tokens: 250, self_assessment_output_tokens: 50, reviewer_subagent_usd: null, reviewer_input_tokens: 0, reviewer_output_tokens: 0, reviewer_direct_fallback_usd: 0.005 } }),
|
|
];
|
|
const block = computeCostBlock(eps, PRICING_TEST);
|
|
expect(block).toMatch(/## Стоимость месяца/);
|
|
expect(block).toMatch(/Classifier/);
|
|
expect(block).toMatch(/Self-assessment/);
|
|
expect(block).toMatch(/Reviewer/);
|
|
expect(block).toMatch(/\$\d+\.\d{2}/);
|
|
});
|
|
|
|
it('returns a block with zeros when episodes array is empty', () => {
|
|
const block = computeCostBlock([], PRICING_TEST);
|
|
expect(block).toMatch(/## Стоимость месяца/);
|
|
expect(block).toMatch(/\$0\.00/);
|
|
});
|
|
});
|
|
|
|
describe('computeAnomalyBlock', () => {
|
|
it('returns "Аномалий нет." when all outputs are within threshold', () => {
|
|
const eps = [
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 100 } }),
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 120 } }),
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 110 } }),
|
|
];
|
|
const block = computeAnomalyBlock(eps);
|
|
expect(block).toMatch(/## Аномалии классификатора/);
|
|
expect(block).toMatch(/Аномалий нет\./);
|
|
});
|
|
|
|
it('lists the outlier episode when one exceeds threshold', () => {
|
|
const eps = [
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 100 } }),
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 100 } }),
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 100 } }),
|
|
makeEp({ task_cost: { ...makeEp().task_cost, classifier_output_tokens: 50000 } }),
|
|
];
|
|
const block = computeAnomalyBlock(eps);
|
|
expect(block).toMatch(/## Аномалии классификатора/);
|
|
expect(block).toMatch(/50000/);
|
|
});
|
|
});
|
|
|
|
describe('computeSelfRetrospectBlock', () => {
|
|
it('returns block with days-ago and no warning when under threshold', () => {
|
|
const fakeFs = {
|
|
existsSync: () => true,
|
|
readFileSync: () => JSON.stringify({ last_run_at: new Date(Date.now() - 2 * 86400000).toISOString(), episodes_since_last: 3, threshold: 10 }),
|
|
};
|
|
const block = computeSelfRetrospectBlock('fake/path.json', fakeFs);
|
|
expect(block).toMatch(/## Авто-ретроспектива/);
|
|
expect(block).toMatch(/2 day\(s\) ago/);
|
|
expect(block).not.toMatch(/⚠️/);
|
|
});
|
|
|
|
it('adds warning when episodes_since_last >= threshold', () => {
|
|
const fakeFs = {
|
|
existsSync: () => true,
|
|
readFileSync: () => JSON.stringify({ last_run_at: new Date(Date.now() - 5 * 86400000).toISOString(), episodes_since_last: 15, threshold: 10 }),
|
|
};
|
|
const block = computeSelfRetrospectBlock('fake/path.json', fakeFs);
|
|
expect(block).toMatch(/⚠️/);
|
|
expect(block).toMatch(/15/);
|
|
});
|
|
|
|
it('returns "never" when counter file is missing', () => {
|
|
const fakeFs = { existsSync: () => false };
|
|
const block = computeSelfRetrospectBlock('fake/path.json', fakeFs);
|
|
expect(block).toMatch(/## Авто-ретроспектива/);
|
|
expect(block).toMatch(/never/);
|
|
});
|
|
});
|
|
|
|
describe('computeReviewerBlock', () => {
|
|
it('shows subagent and fallback counts with percentages', () => {
|
|
const eps = [
|
|
makeEp({ review: { reviewed_at: '2026-05-01T00:00:00Z', reviewer: 'subagent-opus-4-7', reviewer_error: false } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-02T00:00:00Z', reviewer: 'subagent-opus-4-7', reviewer_error: false } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-03T00:00:00Z', reviewer: 'direct-opus-fallback', reviewer_error: false } }),
|
|
makeEp({ review: { reviewed_at: null, reviewer: null, reviewer_error: false } }),
|
|
];
|
|
const block = computeReviewerBlock(eps);
|
|
expect(block).toMatch(/## Reviewer: субагент vs fallback/);
|
|
expect(block).toMatch(/subagent-opus-4-7/);
|
|
expect(block).toMatch(/direct-opus-fallback/);
|
|
expect(block).toMatch(/\d+%/);
|
|
});
|
|
|
|
it('shows fallback message when no episodes were reviewed', () => {
|
|
const eps = [
|
|
makeEp(),
|
|
makeEp(),
|
|
];
|
|
const block = computeReviewerBlock(eps);
|
|
expect(block).toMatch(/## Reviewer: субагент vs fallback/);
|
|
expect(block).toMatch(/0 эпизодов проверено/);
|
|
});
|
|
});
|
|
|
|
// brain-retro #7 C4 (2026-05-27): surface reviewer findings (wrong_skill,
|
|
// wrong_chain_order, alternative suggestions) directly in STATUS.md so the
|
|
// owner doesn't have to run /brain-retro to see them.
|
|
describe('computeReviewerFindingsBlock (C4 — brain-retro #7)', () => {
|
|
it('counts error_root_cause and alternative_better across reviewed episodes', () => {
|
|
const eps = [
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:00:00Z', error_root_cause: 'wrong_skill', alternative_better: '#3', node_quality: 'wrong_node' } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:10:00Z', error_root_cause: 'wrong_skill', alternative_better: '#3', node_quality: 'disputable' } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:20:00Z', error_root_cause: 'wrong_chain_order', alternative_better: '#8', node_quality: 'disputable' } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:30:00Z', error_root_cause: 'external_failure', alternative_better: null, node_quality: 'correct' } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:40:00Z', error_root_cause: 'n/a', alternative_better: null, node_quality: 'correct' } }),
|
|
makeEp({ review: { reviewed_at: null } }), // not reviewed — excluded
|
|
];
|
|
const block = computeReviewerFindingsBlock(eps);
|
|
expect(block).toMatch(/Reviewer findings/);
|
|
expect(block).toMatch(/wrong_skill.*2/);
|
|
expect(block).toMatch(/wrong_chain_order.*1/);
|
|
expect(block).toMatch(/external_failure.*1/);
|
|
// Top alternatives: #3 (x2), #8 (x1)
|
|
expect(block).toMatch(/#3.*2/);
|
|
expect(block).toMatch(/#8.*1/);
|
|
});
|
|
|
|
it('emits "никаких находок" when 0 reviewed episodes with actionable findings', () => {
|
|
const eps = [makeEp(), makeEp()];
|
|
const block = computeReviewerFindingsBlock(eps);
|
|
expect(block).toMatch(/Reviewer findings/);
|
|
expect(block).toMatch(/нет проверенных/i);
|
|
});
|
|
|
|
// batch-reviewer (tools/brain-retro-batch-reviewer.mjs) writes
|
|
// `outcome_reviewed_source: "direct_api_batch"` at episode level and the
|
|
// review object lacks `reviewed_at`. Detect via either signal.
|
|
it('detects batch-reviewed episodes via outcome_reviewed_source (no reviewed_at)', () => {
|
|
const eps = [
|
|
{ ...makeEp(), outcome_reviewed_source: 'direct_api_batch', review: { error_root_cause: 'wrong_skill', alternative_better: '#3', node_quality: 'wrong_node' } },
|
|
{ ...makeEp(), outcome_reviewed_source: 'direct_api_batch', review: { error_root_cause: 'n/a', alternative_better: null, node_quality: 'correct' } },
|
|
];
|
|
const block = computeReviewerFindingsBlock(eps);
|
|
expect(block).toMatch(/Reviewer findings/);
|
|
expect(block).toMatch(/wrong_skill.*1/);
|
|
});
|
|
|
|
it('emits "всё чисто" when reviewed but no wrong_skill / wrong_chain_order', () => {
|
|
const eps = [
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:00:00Z', error_root_cause: 'n/a', alternative_better: null, node_quality: 'correct' } }),
|
|
makeEp({ review: { reviewed_at: '2026-05-26T17:10:00Z', error_root_cause: 'n/a', alternative_better: null, node_quality: 'correct' } }),
|
|
];
|
|
const block = computeReviewerFindingsBlock(eps);
|
|
expect(block).toMatch(/Reviewer findings/);
|
|
expect(block).toMatch(/всё чисто|без проблем/i);
|
|
});
|
|
});
|
|
|
|
// brain-retro #7 C4 follow-up: wiring extra blocks risked introducing
|
|
// markdownlint MD012 (multiple consecutive blank lines) by chaining
|
|
// `\n${block}\n` with blocks whose templates end with `\n`. Guard.
|
|
describe('renderStatus — no double-blank-lines (MD012 lint guard)', () => {
|
|
it('does not emit "\\n\\n\\n" anywhere when all blocks chained', () => {
|
|
const inputs = {
|
|
now: '2026-05-27T04:00:00Z',
|
|
c1: { status: 'ok', detail: 'OK' },
|
|
c2: { status: 'ok', detail: 'OK' },
|
|
c3: { status: 'ok', detail: 'OK' },
|
|
c5: { status: 'ok', detail: 'OK' },
|
|
c6: { status: 'ok', detail: 'OK' },
|
|
observer: { episodeCount: 1, observerErrors: 0, piiMatches: 0 },
|
|
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
|
reviewerBlock: '## Reviewer: субагент vs fallback\n\n0 эпизодов проверено.\n',
|
|
reviewerFindingsBlock: '## Reviewer findings\n\n(нет данных)\n',
|
|
overrideUsageBlock: '## Использование override-фраз\n\n| ph | n |\n|---|---|\n| x | 0 |\n',
|
|
selfRetrospectBlock: '## Авто-ретроспектива\n\nLast: never\n',
|
|
costBlock: '## Cost\n\n$0\n',
|
|
};
|
|
const md = renderStatus(inputs);
|
|
expect(md).not.toMatch(/\n\n\n/);
|
|
});
|
|
});
|
|
|
|
describe('renderStatus — 4 new optional blocks integration', () => {
|
|
const minInputs = {
|
|
now: '2026-05-25T10:00:00Z',
|
|
c1: { status: 'ok', detail: 'OK' },
|
|
c2: { status: 'ok', detail: 'OK' },
|
|
c3: { status: 'ok', detail: 'OK' },
|
|
c5: { status: 'ok', detail: 'OK' },
|
|
c6: { status: 'ok', detail: 'OK' },
|
|
observer: { episodeCount: 5, observerErrors: 0, piiMatches: 0, v1Episodes: 0 },
|
|
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
|
};
|
|
|
|
it('renders all 4 blocks when provided as strings', () => {
|
|
const md = renderStatus({
|
|
...minInputs,
|
|
costBlock: '## Стоимость месяца\ncost content',
|
|
anomalyBlock: '## Аномалии классификатора\nanomaly content',
|
|
selfRetrospectBlock: '## Авто-ретроспектива\nretro content',
|
|
reviewerBlock: '## Reviewer: субагент vs fallback\nreviewer content',
|
|
});
|
|
expect(md).toContain('## Стоимость месяца');
|
|
expect(md).toContain('## Аномалии классификатора');
|
|
expect(md).toContain('## Авто-ретроспектива');
|
|
expect(md).toContain('## Reviewer: субагент vs fallback');
|
|
expect(md).toContain('## Алерт-индикаторы');
|
|
});
|
|
|
|
it('omits all 4 blocks when absent (backward compat)', () => {
|
|
const md = renderStatus(minInputs);
|
|
expect(md).not.toContain('## Стоимость месяца');
|
|
expect(md).not.toContain('## Аномалии классификатора');
|
|
expect(md).not.toContain('## Авто-ретроспектива');
|
|
expect(md).not.toContain('## Reviewer: субагент vs fallback');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// computeSessionLengthBlock — brain-retro #5 candidate B (2026-05-26)
|
|
// Long sessions correlate with discipline drift; surface a warning when any
|
|
// session today (UTC) has ≥50 turns.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
describe('computeSessionLengthBlock', () => {
|
|
const day = '2026-05-26';
|
|
const ep = (turn, opts = {}) => ({
|
|
task_id: opts.id ?? 'sess-1',
|
|
timestamps: { started_at: `${opts.day ?? day}T01:00:0${turn % 10}Z`, ended_at: `${opts.day ?? day}T01:00:0${turn % 10}Z` },
|
|
environment: { session_turn: turn },
|
|
path_type: opts.regulated ? 'regulated' : 'improvised',
|
|
});
|
|
|
|
it('returns "no data" placeholder when episodes empty', () => {
|
|
expect(computeSessionLengthBlock([])).toContain('(нет данных)');
|
|
});
|
|
|
|
it('returns OK (✅) when no session reaches threshold', () => {
|
|
const out = computeSessionLengthBlock([ep(1), ep(2), ep(10)], { now: `${day}T05:00:00Z` });
|
|
expect(out).toContain('✅');
|
|
expect(out).toContain('Ни одной сессии');
|
|
});
|
|
|
|
it('flags a session that crossed threshold', () => {
|
|
const eps = Array.from({ length: 55 }, (_, i) => ep(i + 1));
|
|
const out = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z` });
|
|
expect(out).toContain('⚠️');
|
|
expect(out).toContain('`sess-1');
|
|
expect(out).toContain('55'); // max turn
|
|
});
|
|
|
|
it('respects custom threshold', () => {
|
|
const eps = Array.from({ length: 15 }, (_, i) => ep(i + 1));
|
|
const flagged = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z`, threshold: 10 });
|
|
const notFlagged = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z`, threshold: 20 });
|
|
expect(flagged).toContain('⚠️');
|
|
expect(notFlagged).toContain('✅');
|
|
});
|
|
|
|
it('ignores episodes from other UTC days', () => {
|
|
const eps = Array.from({ length: 55 }, (_, i) => ep(i + 1, { day: '2026-05-25' }));
|
|
const out = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z` });
|
|
expect(out).toContain('✅'); // yesterday's session not counted
|
|
});
|
|
|
|
it('computes regulated % per long session', () => {
|
|
const eps = Array.from({ length: 50 }, (_, i) => ep(i + 1, { regulated: i < 10 }));
|
|
const out = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z`, threshold: 40 });
|
|
expect(out).toContain('⚠️');
|
|
expect(out).toContain('20%'); // 10 regulated out of 50 = 20%
|
|
});
|
|
|
|
it('handles missing session_turn / task_id gracefully', () => {
|
|
const eps = [
|
|
{ task_id: 'x', timestamps: { started_at: `${day}T01:00:00Z` } }, // no session_turn
|
|
{ timestamps: { started_at: `${day}T01:00:00Z` }, environment: { session_turn: 60 } }, // no task_id
|
|
ep(70, { id: 'real' }),
|
|
];
|
|
const out = computeSessionLengthBlock(eps, { now: `${day}T05:00:00Z` });
|
|
expect(out).toContain('⚠️');
|
|
expect(out).toContain('`real');
|
|
expect(out).toContain('70');
|
|
});
|
|
});
|