outputWithContext() emitted a hookSpecificOutput object without the
required hookEventName field, so Claude Code rejected the hook output
("hookSpecificOutput is missing required field hookEventName") and
dropped the context the SessionStart, UserPromptSubmit, and PostToolUse
hooks tried to inject. Thread the event name through the helper; each
caller now passes its own (SessionStart / UserPromptSubmit / PostToolUse).
Existing emission tests now assert hookEventName per event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
138 lines
6 KiB
JavaScript
138 lines
6 KiB
JavaScript
import { describe, it, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'path';
|
|
import { writeFileSync } from 'fs';
|
|
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.equal(out.hookSpecificOutput.hookEventName, 'SessionStart');
|
|
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);
|
|
});
|
|
|
|
it('initializes pushback_count and domain_context fields (v1.1.0)', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's4', cwd: '/tmp' }, dir);
|
|
const state = readState(dir, 's4');
|
|
assert.ok(state);
|
|
assert.equal(state.pushback_count, 0);
|
|
assert.equal(state.domain_context, null);
|
|
});
|
|
|
|
it('initializes v1.2 user-info, valseek, turn_count fields', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's4b', cwd: '/tmp' }, dir);
|
|
const state = readState(dir, 's4b');
|
|
assert.equal(state.user_info_class, null);
|
|
assert.deepEqual(state.user_info_flags, { yes_people: 0, yes_digital: 0, no: 0 });
|
|
assert.equal(state.turn_count, 0);
|
|
assert.equal(state.valseek_count, 0);
|
|
assert.equal(state.valseek_flag, 0);
|
|
});
|
|
});
|
|
|
|
// --- Tier-2 cross-session alert ---
|
|
//
|
|
// Fires at SessionStart when last 3 end records all have user_info_class='no'
|
|
// AND each session had at least one HIGH_STAKES_DOMAINS hit.
|
|
|
|
function writeFixture(dir, records) {
|
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
writeFileSync(join(dir, 'sessions.jsonl'), lines);
|
|
}
|
|
|
|
describe('tier-2 cross-session isolation alert', () => {
|
|
it('fires when 3 prior end records all show no + high-stakes', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 25, user_info_class: 'no', domain_context: ['health'] },
|
|
{ session_id: 'p3', duration_min: 40, user_info_class: 'no', domain_context: ['parenting', 'financial'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew', cwd: '/tmp' }, dir);
|
|
assert.match(out.hookSpecificOutput.additionalContext, /tier-2/);
|
|
});
|
|
|
|
it('does NOT fire when only 2 prior "no" records exist', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['health'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew2', cwd: '/tmp' }, dir);
|
|
const text = out.hookSpecificOutput.additionalContext;
|
|
assert.ok(!/tier-2/.test(text), 'tier-2 must require N consecutive sessions');
|
|
});
|
|
|
|
it('does NOT fire when one record has yes_people class', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'yes_people', domain_context: ['health'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['financial'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew3', cwd: '/tmp' }, dir);
|
|
assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext));
|
|
});
|
|
|
|
it('does NOT fire when any session is in low-stakes domain', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['consumer'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['health'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew4', cwd: '/tmp' }, dir);
|
|
assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext));
|
|
});
|
|
|
|
it('handles v1.1.0 records with string domain_context (backward compat)', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: 'health' }, // string shape
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['parenting'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew5', cwd: '/tmp' }, dir);
|
|
assert.match(out.hookSpecificOutput.additionalContext, /tier-2/);
|
|
});
|
|
});
|