// user-info.test.mjs — verifies v1.2 user-information classifier. // // Three classes: yes_people > yes_digital > no (priority order). // Class is sticky upward — yes_people once set never downgrades. // turn_count increments on every prompt-analyzer invocation. // Step 9 will add the tier-1 alert; this file currently locks the // detection + sticky semantics. import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState } from './test-helper.mjs'; let dir; afterEach(() => { if (dir) cleanupTestDir(dir); }); function freshState() { return { start_epoch: Math.floor(Date.now() / 1000) - 60, start_iso: '2026-05-01T10:00:00Z', tool_count: 0, edit_count: 0, last_event_epoch: 0, burst_count: 0, dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0, pushback_count: 0, domain_context: null, user_info_class: null, user_info_flags: { yes_people: 0, yes_digital: 0, no: 0 }, turn_count: 0, valseek_count: 0, valseek_flag: 0, last_warning_epoch: 0, }; } function runPrompt(prompt, stateOverrides = {}) { dir = setupTestDir(); createStateFile(dir, 'u1', { ...freshState(), ...stateOverrides }); runHook('prompt-analyzer.mjs', { session_id: 'u1', prompt }, dir); return readState(dir, 'u1'); } // --- yes_people detection --- describe('user_info: yes_people patterns', () => { it('matches "my therapist"', () => { const s = runPrompt('I asked my therapist about this'); assert.equal(s.user_info_class, 'yes_people'); assert.equal(s.user_info_flags.yes_people, 1); }); it('matches "my friend"', () => { const s = runPrompt('my friend says I should try meditation'); assert.equal(s.user_info_class, 'yes_people'); }); it('matches "my mentor"', () => { const s = runPrompt('my mentor mentioned this approach'); assert.equal(s.user_info_class, 'yes_people'); }); it('matches "I told my partner"', () => { const s = runPrompt('I told my partner about it last night'); assert.equal(s.user_info_class, 'yes_people'); }); }); describe('user_info: yes_digital patterns', () => { it('matches "I googled"', () => { const s = runPrompt('I googled this and got mixed results'); assert.equal(s.user_info_class, 'yes_digital'); }); it('matches "ChatGPT said"', () => { const s = runPrompt('ChatGPT said the answer was 42'); assert.equal(s.user_info_class, 'yes_digital'); }); it('matches "I read on a forum post"', () => { const s = runPrompt('I read on a forum post that this works'); assert.equal(s.user_info_class, 'yes_digital'); }); }); describe('user_info: no patterns', () => { it('matches "nobody knows"', () => { const s = runPrompt("nobody knows I'm dealing with this"); assert.equal(s.user_info_class, 'no'); }); it('matches "haven\'t told anyone"', () => { const s = runPrompt("I haven't told anyone about it"); assert.equal(s.user_info_class, 'no'); }); it('matches "dealing with this alone"', () => { const s = runPrompt("I'm dealing with this alone"); assert.equal(s.user_info_class, 'no'); }); }); // --- Priority + sticky semantics --- describe('user_info: priority and stickiness', () => { it('yes_people wins over yes_digital in same prompt', () => { const s = runPrompt("I googled it but my therapist said something else"); assert.equal(s.user_info_class, 'yes_people'); // Both counters increment regardless of class outcome. assert.equal(s.user_info_flags.yes_people, 1); assert.equal(s.user_info_flags.yes_digital, 1); }); it('yes_people wins over no in same prompt', () => { const s = runPrompt("nobody knows but I told my friend"); assert.equal(s.user_info_class, 'yes_people'); }); it('yes_digital wins over no in same prompt', () => { const s = runPrompt("nobody knows except what I read on a forum post"); assert.equal(s.user_info_class, 'yes_digital'); }); it('sticky: yes_people set, later yes_digital prompt does NOT downgrade', () => { dir = setupTestDir(); createStateFile(dir, 'u-sticky', freshState()); runHook('prompt-analyzer.mjs', { session_id: 'u-sticky', prompt: 'my therapist suggested journaling' }, dir); runHook('prompt-analyzer.mjs', { session_id: 'u-sticky', prompt: 'I googled the rest' }, dir); const s = readState(dir, 'u-sticky'); assert.equal(s.user_info_class, 'yes_people', 'must not downgrade from people to digital'); assert.equal(s.user_info_flags.yes_digital, 1, 'digital counter still increments'); }); it('sticky: no → yes_people upgrades (lower → higher rank)', () => { dir = setupTestDir(); createStateFile(dir, 'u-up', freshState()); runHook('prompt-analyzer.mjs', { session_id: 'u-up', prompt: 'nobody knows about this' }, dir); runHook('prompt-analyzer.mjs', { session_id: 'u-up', prompt: 'finally told my therapist' }, dir); const s = readState(dir, 'u-up'); assert.equal(s.user_info_class, 'yes_people'); }); it('class stays null when no user-info patterns hit', () => { const s = runPrompt('refactor this typescript module to use generics'); assert.equal(s.user_info_class, null); assert.equal(s.user_info_flags.yes_people, 0); assert.equal(s.user_info_flags.yes_digital, 0); assert.equal(s.user_info_flags.no, 0); }); }); // --- turn_count --- describe('turn_count', () => { it('increments on every prompt-analyzer call', () => { dir = setupTestDir(); createStateFile(dir, 'u-turn', freshState()); for (let i = 0; i < 5; i++) { runHook('prompt-analyzer.mjs', { session_id: 'u-turn', prompt: `prompt ${i}` }, dir); } const s = readState(dir, 'u-turn'); assert.equal(s.turn_count, 5); }); it('handles missing turn_count in pre-v1.2 state files (defaults to 0)', () => { const legacy = freshState(); delete legacy.turn_count; dir = setupTestDir(); createStateFile(dir, 'u-legacy', legacy); runHook('prompt-analyzer.mjs', { session_id: 'u-legacy', prompt: 'hello' }, dir); const s = readState(dir, 'u-legacy'); assert.equal(s.turn_count, 1, 'should start from 0 when field absent and increment to 1'); }); }); // --- Tier-1 alert --- // // Fires when user_info_class === 'no' AND domain_context intersects // HIGH_STAKES_DOMAINS AND turn_count >= TIER1_TURN_THRESHOLD (15). function runPromptCapture(prompt, stateOverrides = {}) { dir = setupTestDir(); createStateFile(dir, 'u-tier1', { ...freshState(), ...stateOverrides }); const out = runHook('prompt-analyzer.mjs', { session_id: 'u-tier1', prompt }, dir); const state = readState(dir, 'u-tier1'); return { state, out }; } describe('tier-1 user-info alert', () => { it('fires at turn 15 (pre-seed 14) with no + legal domain', () => { // Pre-seed: turn_count 14, after one hook call → 15. Triggers alert. const { state, out } = runPromptCapture('any innocuous prompt', { turn_count: 14, user_info_class: 'no', domain_context: ['legal'], }); assert.equal(state.turn_count, 15); assert.ok(out.hookSpecificOutput, 'tier-1 alert should be emitted'); assert.match(out.hookSpecificOutput.additionalContext, /tier-1/); assert.match(out.hookSpecificOutput.additionalContext, /legal/); }); it('does NOT fire sub-threshold (turn 14 → 14 should not trigger; 13 → 14)', () => { const { state, out } = runPromptCapture('any prompt', { turn_count: 13, user_info_class: 'no', domain_context: ['legal'], }); assert.equal(state.turn_count, 14); assert.equal(out.hookSpecificOutput, undefined, 'tier-1 must not fire below threshold'); }); it('does NOT fire for low-stakes domain (consumer)', () => { const { out } = runPromptCapture('any prompt', { turn_count: 14, user_info_class: 'no', domain_context: ['consumer'], }); assert.equal(out.hookSpecificOutput, undefined, 'tier-1 only fires in high-stakes domains'); }); it('does NOT fire when user_info_class is yes_people (supersedes "no")', () => { const { out } = runPromptCapture('any prompt', { turn_count: 14, user_info_class: 'yes_people', domain_context: ['legal'], }); assert.equal(out.hookSpecificOutput, undefined, 'tier-1 only fires when user signals isolation'); }); it('does NOT fire when domain_context is empty', () => { const { out } = runPromptCapture('any prompt', { turn_count: 14, user_info_class: 'no', domain_context: [], }); assert.equal(out.hookSpecificOutput, undefined); }); it('fires for parenting domain (also high-stakes)', () => { const { out } = runPromptCapture('any prompt', { turn_count: 14, user_info_class: 'no', domain_context: ['parenting'], }); assert.ok(out.hookSpecificOutput, 'tier-1 fires for parenting too'); assert.match(out.hookSpecificOutput.additionalContext, /parenting/); }); });