4969363f78
When episode is user_chose_from_options, routing-gate does NOT block — collaborative-choice from Claude-offered options doesn't require a routing-tag (detector is deterministic). 18/18 stop-hook tests GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
8.0 KiB
JavaScript
203 lines
8.0 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision } from './observer-stop-hook.mjs';
|
|
|
|
let workdir;
|
|
|
|
beforeEach(() => {
|
|
workdir = mkdtempSync(join(tmpdir(), 'observer-test-'));
|
|
mkdirSync(join(workdir, 'docs', 'observer'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
const defaultRat = () => ({
|
|
step: 1,
|
|
node_chosen: '#1',
|
|
triggers_matched: [],
|
|
candidates_considered: [],
|
|
boundaries_applied: [],
|
|
hard_floor: { invoked: false, rules: [] },
|
|
task_classification: 'other',
|
|
});
|
|
|
|
// Full schema-v2 episode fixture.
|
|
const v2Episode = (overrides = {}) => ({
|
|
schema_version: 2,
|
|
task_id: 'abc-123',
|
|
task_ref: 'abc-123',
|
|
timestamps: { started_at: '2026-05-19T10:00:00+03:00', ended_at: '2026-05-19T10:05:00+03:00' },
|
|
path_type: 'regulated',
|
|
outcome: 'unknown',
|
|
prompt_signal: 'neutral',
|
|
decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null },
|
|
environment: { economy_level: 0, model: 'claude-opus-4-7', post_compaction: false, session_turn: 1, parallel_session: false },
|
|
task_size: { tool_calls: 0, files_touched: 0, files: [] },
|
|
primary_rationale: defaultRat(),
|
|
events: [],
|
|
...overrides,
|
|
});
|
|
|
|
describe('appendEpisode', () => {
|
|
it('appends one JSONL line to the monthly file', () => {
|
|
appendEpisode(v2Episode(), workdir, '2026-05');
|
|
const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8');
|
|
expect(content).toContain('"task_id":"abc-123"');
|
|
expect(content).toContain('"schema_version":2');
|
|
expect(content.endsWith('\n')).toBe(true);
|
|
});
|
|
|
|
it('appends to an existing file without overwrite', () => {
|
|
appendEpisode(v2Episode({ task_id: 'a' }), workdir, '2026-05');
|
|
appendEpisode(v2Episode({ task_id: 'b', outcome: 'partial' }), workdir, '2026-05');
|
|
const lines = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim().split('\n');
|
|
expect(lines).toHaveLength(2);
|
|
expect(JSON.parse(lines[0]).task_id).toBe('a');
|
|
expect(JSON.parse(lines[1]).task_id).toBe('b');
|
|
});
|
|
|
|
it('applies the PII filter before write (including events[])', () => {
|
|
appendEpisode(
|
|
v2Episode({ events: [{ kind: 'error', message: 'call +79991234567 / mail x@y.com' }] }),
|
|
workdir,
|
|
'2026-05'
|
|
);
|
|
const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8');
|
|
expect(content).toContain('+7XXXXXXXXXX');
|
|
expect(content).toContain('***@***');
|
|
expect(content).not.toContain('79991234567');
|
|
});
|
|
|
|
it('throws on a missing required field', () => {
|
|
expect(() => appendEpisode({}, workdir, '2026-05')).toThrow(/required/i);
|
|
});
|
|
|
|
it('throws on a missing schema-v2 field', () => {
|
|
const ep = v2Episode();
|
|
delete ep.decision_provenance;
|
|
expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/schema v2 field missing/i);
|
|
});
|
|
|
|
it('throws when schema_version is not 2', () => {
|
|
expect(() => appendEpisode(v2Episode({ schema_version: 1 }), workdir, '2026-05')).toThrow(/schema_version/i);
|
|
});
|
|
|
|
it('throws when a primary_rationale sub-field is missing', () => {
|
|
expect(() =>
|
|
appendEpisode(v2Episode({ primary_rationale: { step: 1, node_chosen: '#1' } }), workdir, '2026-05')
|
|
).toThrow(/primary_rationale field missing/i);
|
|
});
|
|
|
|
it('accepts a minimal observer_error marker', () => {
|
|
appendEpisode(
|
|
{
|
|
schema_version: 2,
|
|
observer_error: true,
|
|
error_message: 'parser blew up',
|
|
timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:00:00Z' },
|
|
task_id: 'err-1',
|
|
},
|
|
workdir,
|
|
'2026-05'
|
|
);
|
|
const line = JSON.parse(readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim());
|
|
expect(line.observer_error).toBe(true);
|
|
expect(line.error_message).toBe('parser blew up');
|
|
});
|
|
|
|
it('throws when an observer_error marker is missing a field', () => {
|
|
expect(() =>
|
|
appendEpisode({ schema_version: 2, observer_error: true, task_id: 'x' }, workdir, '2026-05')
|
|
).toThrow(/observer_error marker field missing/i);
|
|
});
|
|
});
|
|
|
|
describe('buildEpisodeFromContext', () => {
|
|
it('builds a v2 episode on the fallback path (no transcript)', () => {
|
|
const ep = buildEpisodeFromContext({ session_id: 'sess-1', result: 'success' });
|
|
expect(ep.schema_version).toBe(2);
|
|
expect(ep.task_id).toBe('sess-1');
|
|
expect(ep.task_ref).toBe('sess-1');
|
|
expect(ep.outcome).toBe('success');
|
|
expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
|
|
expect(ep.environment).toEqual({
|
|
economy_level: null,
|
|
model: null,
|
|
post_compaction: false,
|
|
session_turn: 0,
|
|
parallel_session: false,
|
|
});
|
|
expect(ep.task_size).toEqual({ tool_calls: 0, files_touched: 0, files: [] });
|
|
});
|
|
|
|
it('defaults outcome to unknown when none supplied', () => {
|
|
expect(buildEpisodeFromContext({ session_id: 'x' }).outcome).toBe('unknown');
|
|
});
|
|
|
|
it('derives a v2 episode from transcriptText when provided', () => {
|
|
const transcript = [
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'fix the bug' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 'sess-t' }),
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 'sess-t' }),
|
|
].join('\n');
|
|
const ep = buildEpisodeFromContext({ session_id: 'sess-t' }, transcript);
|
|
expect(ep.schema_version).toBe(2);
|
|
expect(ep.task_id).toBe('sess-t');
|
|
expect(ep.primary_rationale.node_chosen).toBe('superpowers:systematic-debugging');
|
|
});
|
|
});
|
|
|
|
describe('buildObserverError', () => {
|
|
it('produces a minimal valid observer_error marker', () => {
|
|
const marker = buildObserverError({ session_id: 'sess-e' }, new Error('boom'));
|
|
expect(marker.observer_error).toBe(true);
|
|
expect(marker.schema_version).toBe(2);
|
|
expect(marker.task_id).toBe('sess-e');
|
|
expect(marker.error_message).toContain('boom');
|
|
expect(marker.timestamps.started_at).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('routingGateDecision', () => {
|
|
const NODES = ['discovery-interview', 'brainstorming'];
|
|
const autonomousEp = v2Episode();
|
|
const taggedEp = v2Episode({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'brainstorming' } });
|
|
|
|
it('blocks when a method was directed but no routing tag is present', () => {
|
|
const gate = routingGateDecision(autonomousEp, 'запусти discovery-interview', NODES, false);
|
|
expect(gate.block).toBe(true);
|
|
expect(gate.reason).toContain('discovery-interview');
|
|
});
|
|
|
|
it('does not block when the routing tag is present', () => {
|
|
const gate = routingGateDecision(taggedEp, 'запусти discovery-interview', NODES, false);
|
|
expect(gate.block).toBe(false);
|
|
});
|
|
|
|
it('does not block when no method was directed', () => {
|
|
const gate = routingGateDecision(autonomousEp, 'добавь колонку Город', NODES, false);
|
|
expect(gate.block).toBe(false);
|
|
});
|
|
|
|
it('does not block when stop_hook_active is true (loop guard)', () => {
|
|
const gate = routingGateDecision(autonomousEp, 'запусти discovery-interview', NODES, true);
|
|
expect(gate.block).toBe(false);
|
|
});
|
|
|
|
it('does not block for user_chose_from_options even when prompt mentions a node', () => {
|
|
const choiceEp = v2Episode({
|
|
decision_provenance: {
|
|
kind: 'user_chose_from_options',
|
|
node: 'brainstorming',
|
|
options_offered: ['brainstorming', 'writing-plans'],
|
|
claude_would_have_chosen: 'brainstorming',
|
|
},
|
|
});
|
|
const gate = routingGateDecision(choiceEp, 'запусти brainstorming', NODES, false);
|
|
expect(gate.block).toBe(false);
|
|
});
|
|
});
|