feat: add ai-psychosis plugin to open marketplace
Meta-awareness tools for healthy AI interaction patterns. Detects reinforcement loops, scope escalation, and compulsive patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f93d6abdae
commit
297867f847
19 changed files with 1920 additions and 0 deletions
44
plugins/ai-psychosis/tests/privacy.test.mjs
Normal file
44
plugins/ai-psychosis/tests/privacy.test.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { runHook, setupTestDir, cleanupTestDir } from './test-helper.mjs';
|
||||
|
||||
let dir;
|
||||
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
||||
|
||||
function readAllFiles(dirPath) {
|
||||
let content = '';
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
const full = join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
content += readAllFiles(full);
|
||||
} else {
|
||||
content += readFileSync(full, 'utf8');
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
describe('privacy', () => {
|
||||
it('never writes prompt text to disk through full lifecycle', () => {
|
||||
dir = setupTestDir();
|
||||
const canary = 'CANARY_PRIVACY_xyz123';
|
||||
|
||||
// 1. Session start
|
||||
runHook('session-start.mjs', { session_id: 'priv1', cwd: '/tmp' }, dir);
|
||||
|
||||
// 2. Prompt analysis with canary as prompt text
|
||||
runHook('prompt-analyzer.mjs', { session_id: 'priv1', prompt: `tell me what to do ${canary} am I right?` }, dir);
|
||||
|
||||
// 3. Tool tracking
|
||||
runHook('tool-tracker.mjs', { session_id: 'priv1', tool_name: 'Edit' }, dir);
|
||||
|
||||
// 4. Session end
|
||||
runHook('session-end.mjs', { session_id: 'priv1', cwd: '/tmp' }, dir);
|
||||
|
||||
// Read ALL files recursively — canary must not appear anywhere
|
||||
const allContent = readAllFiles(dir);
|
||||
assert.ok(!allContent.includes(canary), `Canary "${canary}" found in data files — privacy violation`);
|
||||
});
|
||||
});
|
||||
313
plugins/ai-psychosis/tests/prompt-analyzer.test.mjs
Normal file
313
plugins/ai-psychosis/tests/prompt-analyzer.test.mjs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
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;
|
||||
|
||||
function freshState() {
|
||||
return {
|
||||
start_epoch: Math.floor(Date.now() / 1000) - 60,
|
||||
start_iso: '2026-01-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,
|
||||
last_warning_epoch: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function runPrompt(prompt, stateOverrides = {}) {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), ...stateOverrides });
|
||||
runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt }, dir);
|
||||
return readState(dir, 'p1');
|
||||
}
|
||||
|
||||
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
||||
|
||||
// --- Dependency patterns (7 positive, 7 negative) ---
|
||||
|
||||
describe('dependency patterns', () => {
|
||||
it('matches "tell me what to do"', () => {
|
||||
const s = runPrompt('please tell me what to do');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "I told him the plan"', () => {
|
||||
const s = runPrompt('I told him the plan');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "what should I do"', () => {
|
||||
const s = runPrompt('what should I do next?');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "I know what to build"', () => {
|
||||
const s = runPrompt('I know what to build');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "am I right"', () => {
|
||||
const s = runPrompt('am I right about this?');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "I turned the server right"', () => {
|
||||
const s = runPrompt('I turned the server on');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "you understand me"', () => {
|
||||
const s = runPrompt('you understand me well');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "do you understand merging?"', () => {
|
||||
const s = runPrompt('do you understand merging?');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "you\'re the only"', () => {
|
||||
const s = runPrompt("you're the only one who gets it");
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "the only option is refactoring"', () => {
|
||||
const s = runPrompt('the only option is refactoring');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "can I do this"', () => {
|
||||
const s = runPrompt('can I do this alone?');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "we can implement this later"', () => {
|
||||
const s = runPrompt('we can implement this later');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "I need you to decide"', () => {
|
||||
const s = runPrompt('I need you to decide for me');
|
||||
assert.equal(s.dep_flags, 1);
|
||||
});
|
||||
it('does not match "we need to deploy soon"', () => {
|
||||
const s = runPrompt('we need to deploy soon');
|
||||
assert.equal(s.dep_flags, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Escalation patterns (6 positive, 6 negative) ---
|
||||
|
||||
describe('escalation patterns', () => {
|
||||
it('matches "definitely" as word', () => {
|
||||
const s = runPrompt('this is definitely wrong');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "definitively"', () => {
|
||||
const s = runPrompt('this is definitively proven');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "clearly" as word', () => {
|
||||
const s = runPrompt('clearly this is the issue');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "nuclear unclear"', () => {
|
||||
const s = runPrompt('nuclear unclear situation');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "this proves"', () => {
|
||||
const s = runPrompt('this proves my point');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "prove this theorem"', () => {
|
||||
const s = runPrompt('prove this theorem');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "obviously" as word', () => {
|
||||
const s = runPrompt('obviously we should refactor');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "not an obvious choice"', () => {
|
||||
const s = runPrompt('not an obvious choice');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "without a doubt"', () => {
|
||||
const s = runPrompt('without a doubt this works');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "I have some doubt"', () => {
|
||||
const s = runPrompt('I have some doubt about it');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "this confirms"', () => {
|
||||
const s = runPrompt('this confirms the theory');
|
||||
assert.equal(s.esc_flags, 1);
|
||||
});
|
||||
it('does not match "please confirm the deploy"', () => {
|
||||
const s = runPrompt('please confirm the deploy');
|
||||
assert.equal(s.esc_flags, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fatigue patterns (7 positive, 7 negative) ---
|
||||
|
||||
describe('fatigue patterns', () => {
|
||||
it('matches "tired"', () => {
|
||||
const s = runPrompt("I'm tired of debugging");
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "retired"', () => {
|
||||
const s = runPrompt('I retired last year');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "exhausted"', () => {
|
||||
const s = runPrompt("I'm exhausted.");
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "exhaustive"', () => {
|
||||
const s = runPrompt('the options were exhaustive');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "can\'t think"', () => {
|
||||
const s = runPrompt("I can't think straight");
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "I can think clearly"', () => {
|
||||
const s = runPrompt('I can think of a solution');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "been at this"', () => {
|
||||
const s = runPrompt("I've been at this all day");
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "haven\'t been at home"', () => {
|
||||
const s = runPrompt("I haven't been at home");
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "it\'s late"', () => {
|
||||
const s = runPrompt("it's late, wrapping up");
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "the latest version"', () => {
|
||||
const s = runPrompt('the latest version is good');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "should sleep"', () => {
|
||||
const s = runPrompt('I should sleep');
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "sleep mode is enabled"', () => {
|
||||
const s = runPrompt('enable sleep mode');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "hours now"', () => {
|
||||
const s = runPrompt('been going for hours now');
|
||||
assert.equal(s.fatigue_flags, 1);
|
||||
});
|
||||
it('does not match "hourly updates"', () => {
|
||||
const s = runPrompt('hourly updates are fine');
|
||||
assert.equal(s.fatigue_flags, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Validation patterns (5 positive, 5 negative) ---
|
||||
|
||||
describe('validation patterns', () => {
|
||||
it('matches "right?"', () => {
|
||||
const s = runPrompt('this works, right?');
|
||||
assert.equal(s.val_flags, 1);
|
||||
});
|
||||
it('does not match "turn right"', () => {
|
||||
const s = runPrompt('turn right at the fork');
|
||||
assert.equal(s.val_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "don\'t you think"', () => {
|
||||
const s = runPrompt("don't you think so?");
|
||||
assert.equal(s.val_flags, 1);
|
||||
});
|
||||
it('does not match "I don\'t think so"', () => {
|
||||
const s = runPrompt("I don't think so");
|
||||
assert.equal(s.val_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "you agree"', () => {
|
||||
const s = runPrompt('you agree with me');
|
||||
assert.equal(s.val_flags, 1);
|
||||
});
|
||||
it('does not match "if parties agree"', () => {
|
||||
const s = runPrompt('if parties agree on terms');
|
||||
assert.equal(s.val_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "correct?"', () => {
|
||||
const s = runPrompt('is this correct?');
|
||||
assert.equal(s.val_flags, 1);
|
||||
});
|
||||
it('does not match "correct the typo"', () => {
|
||||
const s = runPrompt("I'll correct the typo");
|
||||
assert.equal(s.val_flags, 0);
|
||||
});
|
||||
|
||||
it('matches "isn\'t it"', () => {
|
||||
const s = runPrompt('good approach, isn\'t it');
|
||||
assert.equal(s.val_flags, 1);
|
||||
});
|
||||
it('does not match "it isn\'t working"', () => {
|
||||
const s = runPrompt("it isn't working yet");
|
||||
assert.equal(s.val_flags, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Threshold and cooldown tests (6 cases) ---
|
||||
|
||||
describe('thresholds and cooldowns', () => {
|
||||
it('warns at dependency soft threshold (2 flags)', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 1 });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Dependency language noticed'));
|
||||
});
|
||||
|
||||
it('warns hard at dependency threshold (5 flags)', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 4 });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('INTERACTION AWARENESS'));
|
||||
});
|
||||
|
||||
it('fatigue bypasses cooldown', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), last_warning_epoch: Math.floor(Date.now() / 1000) });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: "I'm tired" }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Fatigue language detected'));
|
||||
});
|
||||
|
||||
it('cooldown suppresses non-fatigue warning', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 4, last_warning_epoch: Math.floor(Date.now() / 1000) });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
|
||||
assert.equal(out.continue, true);
|
||||
assert.ok(!out.hookSpecificOutput);
|
||||
});
|
||||
|
||||
it('warns at escalation threshold (3 flags)', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), esc_flags: 2 });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'this is definitely the issue' }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Escalation language detected'));
|
||||
});
|
||||
|
||||
it('warns at validation threshold (3 flags)', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'p1', { ...freshState(), val_flags: 2 });
|
||||
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'this is correct, right?' }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Validation-seeking pattern'));
|
||||
});
|
||||
});
|
||||
66
plugins/ai-psychosis/tests/session-end.test.mjs
Normal file
66
plugins/ai-psychosis/tests/session-end.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { runHook, setupTestDir, cleanupTestDir, createStateFile, readJsonl } from './test-helper.mjs';
|
||||
|
||||
let dir;
|
||||
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
||||
|
||||
describe('session-end', () => {
|
||||
it('finalizes session record and deletes state file', () => {
|
||||
dir = setupTestDir();
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
createStateFile(dir, 's1', {
|
||||
start_epoch: nowEpoch - 300, start_iso: '2026-01-01T10:00:00Z',
|
||||
tool_count: 5, edit_count: 2,
|
||||
dep_flags: 1, esc_flags: 0, fatigue_flags: 0, val_flags: 1,
|
||||
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
|
||||
});
|
||||
runHook('session-end.mjs', { session_id: 's1', cwd: '/tmp' }, dir);
|
||||
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
||||
const end = records.find(r => r.end);
|
||||
assert.ok(end);
|
||||
assert.equal(end.session_id, 's1');
|
||||
assert.equal(end.tool_count, 5);
|
||||
assert.equal(end.edit_count, 2);
|
||||
assert.ok(!existsSync(join(dir, 'state', 's1.json')));
|
||||
});
|
||||
|
||||
it('computes duration correctly', () => {
|
||||
dir = setupTestDir();
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
createStateFile(dir, 's2', {
|
||||
start_epoch: nowEpoch - 3600, start_iso: '2026-01-01T10:00:00Z',
|
||||
tool_count: 10, edit_count: 3,
|
||||
dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0,
|
||||
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
|
||||
});
|
||||
runHook('session-end.mjs', { session_id: 's2', cwd: '/tmp' }, dir);
|
||||
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
||||
const end = records.find(r => r.end);
|
||||
assert.ok(end.duration_min >= 59 && end.duration_min <= 61);
|
||||
});
|
||||
|
||||
it('preserves flags in final record', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 's3', {
|
||||
start_epoch: Math.floor(Date.now() / 1000) - 60, start_iso: '2026-01-01T10:00:00Z',
|
||||
tool_count: 1, edit_count: 0,
|
||||
dep_flags: 3, esc_flags: 1, fatigue_flags: 2, val_flags: 0,
|
||||
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
|
||||
});
|
||||
runHook('session-end.mjs', { session_id: 's3', cwd: '/tmp' }, dir);
|
||||
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
||||
const end = records.find(r => r.end);
|
||||
assert.deepEqual(end.flags, { dependency: 3, escalation: 1, fatigue: 2, validation: 0 });
|
||||
});
|
||||
|
||||
it('handles missing state file gracefully', () => {
|
||||
dir = setupTestDir();
|
||||
runHook('session-end.mjs', { session_id: 'missing', cwd: '/tmp' }, dir);
|
||||
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].note, 'no_state_file');
|
||||
});
|
||||
});
|
||||
49
plugins/ai-psychosis/tests/session-start.test.mjs
Normal file
49
plugins/ai-psychosis/tests/session-start.test.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'path';
|
||||
import { runHook, setupTestDir, cleanupTestDir, readState, readJsonl } from './test-helper.mjs';
|
||||
|
||||
let dir;
|
||||
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
||||
|
||||
describe('session-start', () => {
|
||||
it('creates state file and emits context', () => {
|
||||
dir = setupTestDir();
|
||||
const out = runHook('session-start.mjs', { session_id: 's1', cwd: '/tmp' }, dir);
|
||||
assert.equal(out.continue, true);
|
||||
assert.ok(out.hookSpecificOutput.additionalContext.includes('Interaction Awareness is active'));
|
||||
const state = readState(dir, 's1');
|
||||
assert.ok(state);
|
||||
assert.equal(state.tool_count, 0);
|
||||
assert.equal(state.edit_count, 0);
|
||||
assert.equal(state.dep_flags, 0);
|
||||
});
|
||||
|
||||
it('writes start record to sessions.jsonl', () => {
|
||||
dir = setupTestDir();
|
||||
runHook('session-start.mjs', { session_id: 's2', cwd: '/tmp' }, dir);
|
||||
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].session_id, 's2');
|
||||
assert.ok('hour' in records[0]);
|
||||
assert.ok('is_late_night' in records[0]);
|
||||
});
|
||||
|
||||
it('state has correct initial fields', () => {
|
||||
dir = setupTestDir();
|
||||
runHook('session-start.mjs', { session_id: 's3', cwd: '/tmp' }, dir);
|
||||
const state = readState(dir, 's3');
|
||||
assert.equal(state.burst_count, 0);
|
||||
assert.equal(state.last_event_epoch, 0);
|
||||
assert.equal(state.last_warning_epoch, 0);
|
||||
assert.ok(state.start_epoch > 0);
|
||||
assert.ok(state.start_iso.length > 0);
|
||||
});
|
||||
|
||||
it('returns continue with no side effects when session_id missing', () => {
|
||||
dir = setupTestDir();
|
||||
const out = runHook('session-start.mjs', { cwd: '/tmp' }, dir);
|
||||
assert.equal(out.continue, true);
|
||||
assert.ok(!out.hookSpecificOutput);
|
||||
});
|
||||
});
|
||||
53
plugins/ai-psychosis/tests/test-helper.mjs
Normal file
53
plugins/ai-psychosis/tests/test-helper.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Shared test utilities for hook script tests.
|
||||
// Uses node:child_process to pipe JSON stdin to hook scripts.
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const SCRIPTS_DIR = join(import.meta.dirname, '..', 'hooks', 'scripts');
|
||||
|
||||
export function runHook(scriptName, stdinJson, dataDir) {
|
||||
const input = typeof stdinJson === 'string' ? stdinJson : JSON.stringify(stdinJson);
|
||||
const env = { ...process.env, CLAUDE_PLUGIN_DATA: dataDir };
|
||||
const stdout = execSync(`node ${join(SCRIPTS_DIR, scriptName)}`, {
|
||||
input,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
try {
|
||||
return JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
return { raw: stdout.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTestDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ia-test-'));
|
||||
mkdirSync(join(dir, 'state'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function cleanupTestDir(dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function createStateFile(dir, sid, state) {
|
||||
writeFileSync(join(dir, 'state', `${sid}.json`), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function readState(dir, sid) {
|
||||
const f = join(dir, 'state', `${sid}.json`);
|
||||
if (!existsSync(f)) return null;
|
||||
return JSON.parse(readFileSync(f, 'utf8'));
|
||||
}
|
||||
|
||||
export function readJsonl(filePath) {
|
||||
if (!existsSync(filePath)) return [];
|
||||
return readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => JSON.parse(line));
|
||||
}
|
||||
94
plugins/ai-psychosis/tests/tool-tracker.test.mjs
Normal file
94
plugins/ai-psychosis/tests/tool-tracker.test.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'path';
|
||||
import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState, readJsonl } from './test-helper.mjs';
|
||||
|
||||
let dir;
|
||||
|
||||
function freshState(overrides = {}) {
|
||||
return {
|
||||
start_epoch: Math.floor(Date.now() / 1000) - 60,
|
||||
start_iso: '2026-01-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,
|
||||
last_warning_epoch: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
||||
|
||||
describe('tool-tracker', () => {
|
||||
it('tracks tool call and increments tool_count', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't1', freshState());
|
||||
runHook('tool-tracker.mjs', { session_id: 't1', tool_name: 'Read' }, dir);
|
||||
const s = readState(dir, 't1');
|
||||
assert.equal(s.tool_count, 1);
|
||||
const events = readJsonl(join(dir, 'events.jsonl'));
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].tool_name, 'Read');
|
||||
assert.equal(events[0].session_id, 't1');
|
||||
});
|
||||
|
||||
it('increments edit_count for Edit tool', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't2', freshState());
|
||||
runHook('tool-tracker.mjs', { session_id: 't2', tool_name: 'Edit' }, dir);
|
||||
const s = readState(dir, 't2');
|
||||
assert.equal(s.edit_count, 1);
|
||||
});
|
||||
|
||||
it('does not increment edit_count for non-Edit tool', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't3', freshState());
|
||||
runHook('tool-tracker.mjs', { session_id: 't3', tool_name: 'Bash' }, dir);
|
||||
const s = readState(dir, 't3');
|
||||
assert.equal(s.edit_count, 0);
|
||||
});
|
||||
|
||||
it('detects burst when interval < 30s', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't4', freshState({
|
||||
last_event_epoch: Math.floor(Date.now() / 1000) - 5,
|
||||
burst_count: 0,
|
||||
}));
|
||||
runHook('tool-tracker.mjs', { session_id: 't4', tool_name: 'Read' }, dir);
|
||||
const s = readState(dir, 't4');
|
||||
assert.equal(s.burst_count, 1);
|
||||
});
|
||||
|
||||
it('resets burst when interval >= 30s', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't5', freshState({
|
||||
last_event_epoch: Math.floor(Date.now() / 1000) - 60,
|
||||
burst_count: 3,
|
||||
}));
|
||||
runHook('tool-tracker.mjs', { session_id: 't5', tool_name: 'Read' }, dir);
|
||||
const s = readState(dir, 't5');
|
||||
assert.equal(s.burst_count, 0);
|
||||
});
|
||||
|
||||
it('emits periodic reminder at modulo 25', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't6', freshState({ tool_count: 24 }));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 't6', tool_name: 'Read' }, dir);
|
||||
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('REMINDER'));
|
||||
});
|
||||
|
||||
it('outputs continue between checkpoints', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 't7', freshState({ tool_count: 5 }));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 't7', tool_name: 'Read' }, dir);
|
||||
assert.equal(out.continue, true);
|
||||
assert.ok(!out.hookSpecificOutput);
|
||||
});
|
||||
|
||||
it('handles missing state file gracefully', () => {
|
||||
dir = setupTestDir();
|
||||
// No state file created
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 'missing', tool_name: 'Read' }, dir);
|
||||
assert.equal(out.continue, true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue