247 lines
8.8 KiB
JavaScript
247 lines
8.8 KiB
JavaScript
// 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/);
|
|
});
|
|
});
|