mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-07 08:35:54 +00:00
Reject numeric prefixes, fractions and unsafe window integers consistently with summary scheduling while preserving resolver precedence. Cap only the default context reminder at eighty percent of a valid earlier native compaction budget; explicit thresholds, including zero, retain their intentional policy. Document the independent capacity, summary and advisory contracts. RED controls reproduce malformed-budget summary triggers and the known-1M/native-200k missed reminder. Resolver65, helper47, actualStop18 and actualPreToolUse54 pass on Node18 and24. Parent and independent reviewer approved the exact diff before commit. Registry rebinding remains the integration coordinator responsibility after combining documentation repairs.
446 lines
21 KiB
JavaScript
446 lines
21 KiB
JavaScript
/**
|
|
* Tests for session-end.js hook
|
|
*
|
|
* Run with: node tests/hooks/session-end.test.js
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const { getDateString, sanitizeSessionId } = require('../../scripts/lib/utils');
|
|
|
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'session-end.js');
|
|
const START = '<!-- ECC:SUMMARY:START -->';
|
|
const END = '<!-- ECC:SUMMARY:END -->';
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function countOccurrences(haystack, needle) {
|
|
let n = 0;
|
|
let i = 0;
|
|
while ((i = haystack.indexOf(needle, i)) !== -1) {
|
|
n += 1;
|
|
i += needle.length;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function isolatedHomeEnv(home) {
|
|
return {
|
|
...process.env, HOME: home, USERPROFILE: home,
|
|
ECC_AGENT_DATA_HOME: path.join(home, '.claude'),
|
|
CLAUDE_CONFIG_DIR: path.join(home, '.claude')
|
|
};
|
|
}
|
|
|
|
function runHook(home, transcript, env = {}) {
|
|
return spawnSync(process.execPath, [script], {
|
|
encoding: 'utf8',
|
|
input: transcript ? JSON.stringify({ transcript_path: transcript }) : '',
|
|
env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: '', ...env },
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
|
|
function sessionFileFor(home, uuid, date = getDateString()) {
|
|
const shortId = sanitizeSessionId(uuid.slice(-8).toLowerCase());
|
|
return path.join(home, '.claude', 'session-data', `${date}-${shortId}-session.tmp`);
|
|
}
|
|
|
|
function runTests() {
|
|
console.log('\n=== Testing session-end.js ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
// Regression: a user message containing $-sequences ($&, $$, $`, $') must be
|
|
// written verbatim into the rewritten summary block. The block is fed to
|
|
// String.prototype.replace as the replacement argument, where those sequences
|
|
// are special — without escaping/a function replacer they corrupt the summary
|
|
// (e.g. $& injects the entire matched old block, duplicating the markers).
|
|
(test('preserves $-sequences in user messages when rewriting the summary block', () => {
|
|
// Isolate HOME so getSessionsDir() resolves under a temp dir.
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const sessionsDir = path.join(home, '.claude', 'session-data');
|
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
// shortId is derived from the transcript filename UUID (last 8 chars).
|
|
const uuid = 'abcdef12-3456-7890-abcd-ef0123456789';
|
|
const shortId = sanitizeSessionId(uuid.slice(-8).toLowerCase());
|
|
const today = getDateString();
|
|
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
|
|
|
// Pre-seed a session file that already has summary markers, so the
|
|
// idempotent rewrite path runs .replace() with the new summary block.
|
|
fs.writeFileSync(
|
|
sessionFile,
|
|
`# Session: ${today}\n**Date:** ${today}\n---\n${START}\n## Session Summary\n\n### Tasks\n- old task\n${END}\n`
|
|
);
|
|
|
|
// Transcript whose user message contains replacement-special $-sequences.
|
|
const userText = 'release $& fallback $$ done';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(
|
|
transcript,
|
|
[
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: userText } }),
|
|
JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/release.js' } }),
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const res = spawnSync('node', [script], {
|
|
encoding: 'utf8',
|
|
input: JSON.stringify({ transcript_path: transcript }),
|
|
env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: '' },
|
|
timeout: 10000,
|
|
});
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
|
|
const out = fs.readFileSync(sessionFile, 'utf8');
|
|
// User text must survive verbatim (no $&/$$ interpretation).
|
|
assert.ok(out.includes(`- ${userText}`), `expected verbatim user text in:\n${out}`);
|
|
// Exactly one marker pair — a $& bug re-injects the matched block, duplicating markers.
|
|
assert.strictEqual(countOccurrences(out, START), 1, `START marker should appear once:\n${out}`);
|
|
assert.strictEqual(countOccurrences(out, END), 1, `END marker should appear once:\n${out}`);
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('writes a session for a multi-message transcript', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const uuid = '11111111-2222-4333-8444-555555555555';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(
|
|
transcript,
|
|
[
|
|
JSON.stringify({ type: 'user', content: 'Investigate the failing hook' }),
|
|
JSON.stringify({ type: 'user', content: 'Add regression coverage' }),
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const res = runHook(home, transcript);
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
|
|
const sessionFile = sessionFileFor(home, uuid);
|
|
const out = fs.readFileSync(sessionFile, 'utf8');
|
|
assert.ok(out.includes(START), 'Should include the generated summary start marker');
|
|
assert.ok(out.includes(END), 'Should include the generated summary end marker');
|
|
assert.ok(out.includes('**Last Updated:**'), 'Should include session metadata');
|
|
assert.ok(out.includes('Add regression coverage'), 'Should include the latest user task');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('writes a session for one user message with tool activity', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const uuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(
|
|
transcript,
|
|
[
|
|
JSON.stringify({ type: 'user', content: 'Fix the configuration' }),
|
|
JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/config.js' } }),
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const res = runHook(home, transcript);
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'Tool activity should make the session eligible');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('writes a session for a normal one-message prompt without tool activity', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Print the current version' }) + '\n');
|
|
|
|
const res = runHook(home, transcript);
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'A normal short user session should remain resumable');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('skips a one-message summarizer-style transcript without prompt matching', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const uuid = 'fedcba98-7654-4321-8765-fedcba987654';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(
|
|
transcript,
|
|
[
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'Summarize the supplied conversation as concise markdown.' } }),
|
|
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: '## Summary\nThe hook behavior was reviewed.' } }),
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const res = runHook(home, transcript, { ECC_LLM_SUMMARY_SUBPROCESS: '1' });
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
assert.ok(!fs.existsSync(sessionFileFor(home, uuid)), 'Summarizer subprocess should not create a session file');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('does not rewrite an existing session for a rejected transcript', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const uuid = '99999999-8888-4777-8666-555555555555';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
const sessionFile = sessionFileFor(home, uuid);
|
|
const original = '# Session: preserved\n**Last Updated:** 09:00\n\n---\n\nUser-authored context\n';
|
|
const originalTime = new Date('2026-01-02T03:04:05.000Z');
|
|
|
|
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
fs.writeFileSync(sessionFile, original);
|
|
fs.utimesSync(sessionFile, originalTime, originalTime);
|
|
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Internal summary request' }) + '\n');
|
|
|
|
const res = runHook(home, transcript, { ECC_LLM_SUMMARY_SUBPROCESS: '1' });
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
assert.strictEqual(fs.readFileSync(sessionFile, 'utf8'), original, 'Internal summarizer should not change existing content');
|
|
assert.strictEqual(fs.statSync(sessionFile).mtimeMs, originalTime.getTime(), 'Internal summarizer should not advance mtime');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('keeps fallback behavior when transcript metadata is malformed', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-'));
|
|
try {
|
|
const res = spawnSync('node', [script], {
|
|
encoding: 'utf8',
|
|
input: '{not-json',
|
|
env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: 'fallback-session-12345678', CLAUDE_TRANSCRIPT_PATH: '' },
|
|
timeout: 10000,
|
|
});
|
|
assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`);
|
|
|
|
const sessionsDir = path.join(home, '.claude', 'session-data');
|
|
assert.strictEqual(fs.readdirSync(sessionsDir).filter(name => name.endsWith('-session.tmp')).length, 1, 'Fallback should still create the placeholder session');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
(test('persists mechanical resume state when the LLM budget is exhausted', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-budget-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Keep this task resumable' }) + '\n');
|
|
const bin = path.join(home, 'empty-bin');
|
|
fs.mkdirSync(bin);
|
|
const res = runHook(home, transcript, {
|
|
ECC_LLM_SUMMARY_INTERVAL: '1', ECC_HOOK_DEADLINE_MS: '1',
|
|
ECC_SKIP_LLM_SUMMARY: '', ECC_LLM_SUMMARY_SUBPROCESS: '', PATH: bin
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
assert.match(res.stderr, /LLM summary skipped.*insufficient lifecycle time budget/);
|
|
assert.doesNotMatch(res.stderr, /LLM summary failed/);
|
|
assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Keep this task resumable/);
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
for (const model of ['unknown-model', 'claude-opus-4-5[1m]']) {
|
|
(test(`native compact budget triggers a timely summary for ${model}`, () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-context-budget-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(transcript, [
|
|
JSON.stringify({ type: 'user', content: 'Preserve this task before native compaction' }),
|
|
JSON.stringify({ type: 'assistant', message: { model, content: [], usage: { input_tokens: 330000 } } })
|
|
].join('\n') + '\n');
|
|
const res = runHook(home, transcript, {
|
|
ECC_CONTEXT_WINDOW_TOKENS: '', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '400000',
|
|
ECC_LLM_SUMMARY_INTERVAL: '50', ECC_LLM_SUMMARY_CONTEXT_THRESHOLD: '20',
|
|
ECC_HOOK_DEADLINE_MS: '1', ECC_SKIP_LLM_SUMMARY: '', ECC_LLM_SUMMARY_SUBPROCESS: ''
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
assert.match(res.stderr, /LLM summary triggered.*summary budget: 18%/);
|
|
assert.match(res.stderr, /LLM summary skipped.*insufficient lifecycle time budget/);
|
|
assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Preserve this task before native compaction/);
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
}
|
|
|
|
for (const nativeBudget of ['200000tokens', '200000.5', '9007199254740992']) {
|
|
(test(`invalid native budget ${nativeBudget} does not trigger a summary from inferred capacity`, () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-invalid-budget-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
fs.writeFileSync(transcript, [
|
|
JSON.stringify({ type: 'user', content: 'Retain mechanical resume state with an invalid budget' }),
|
|
JSON.stringify({ type: 'assistant', message: { model: 'unknown-model', content: [], usage: { input_tokens: 180000 } } }),
|
|
].join('\n') + '\n');
|
|
const res = runHook(home, transcript, {
|
|
ECC_CONTEXT_WINDOW_TOKENS: '', CLAUDE_CODE_AUTO_COMPACT_WINDOW: nativeBudget,
|
|
ECC_LLM_SUMMARY_INTERVAL: '50', ECC_LLM_SUMMARY_CONTEXT_THRESHOLD: '20',
|
|
ECC_HOOK_DEADLINE_MS: '1', ECC_SKIP_LLM_SUMMARY: '', ECC_LLM_SUMMARY_SUBPROCESS: '',
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(res.stderr, /LLM summary triggered/);
|
|
assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Retain mechanical resume state with an invalid budget/);
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
}
|
|
|
|
(test('logs an explicit LLM disable as skipped and persists mechanical resume state', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-skip-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
const bin = path.join(home, 'empty-bin');
|
|
fs.mkdirSync(bin);
|
|
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Save context with LLM disabled' }) + '\n');
|
|
const res = runHook(home, transcript, {
|
|
ECC_LLM_SUMMARY_INTERVAL: '1', ECC_SKIP_LLM_SUMMARY: '1',
|
|
ECC_LLM_SUMMARY_SUBPROCESS: '', PATH: bin
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
assert.match(res.stderr, /LLM summary skipped.*ECC_SKIP_LLM_SUMMARY/);
|
|
assert.doesNotMatch(res.stderr, /LLM summary failed/);
|
|
assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Save context with LLM disabled/);
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
|
|
for (const inherited of ['', String(Date.now() + 300000), '1']) {
|
|
(test(`direct legacy runner bounds the deadline ${inherited || '(unset)'}`, () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-budget-'));
|
|
try {
|
|
const relative = 'legacy.js';
|
|
fs.writeFileSync(path.join(home, relative), "process.stdout.write(JSON.stringify({deadline:Number(process.env.ECC_HOOK_DEADLINE_MS),now:Date.now()}));\n");
|
|
const runner = path.resolve(__dirname, '../../scripts/hooks/run-with-flags.js');
|
|
const res = spawnSync(process.execPath, [runner, 'session:stop:session-end', relative, 'standard'], {
|
|
input: '{}', encoding: 'utf8', timeout: 5000,
|
|
env: {
|
|
...isolatedHomeEnv(home), CLAUDE_PLUGIN_ROOT: home,
|
|
ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: '', ECC_DRY_RUN: '0', ECC_HOOKS_ENABLED: 'true',
|
|
ECC_HOOK_DEADLINE_MS: inherited
|
|
}
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
if (inherited === '1') {
|
|
assert.strictEqual(res.stdout, '');
|
|
assert.match(res.stderr, /time budget exhausted/);
|
|
} else {
|
|
const result = JSON.parse(res.stdout);
|
|
assert.ok(result.deadline > result.now);
|
|
assert.ok(result.deadline - result.now <= 30000, 'Inherited far-future deadlines must not extend the runner budget');
|
|
}
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
}
|
|
|
|
// A local stand-in exercises the actual bootstrap -> runner -> Stop hook ->
|
|
// child timeout chain without invoking Claude or using authentication.
|
|
if (process.platform !== 'win32') {
|
|
(test('times out a slow summarizer across a calendar-day rollover and saves fallback', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-budget-'));
|
|
try {
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const transcript = path.join(home, `${uuid}.jsonl`);
|
|
const bin = path.join(home, 'bin');
|
|
const started = path.join(home, 'started');
|
|
const calendarAfter = path.join(home, 'calendar-after');
|
|
const calendarPreload = path.join(home, 'calendar.cjs');
|
|
const summarizer = path.join(home, 'summarizer.cjs');
|
|
const sessionDate = '2000-01-01';
|
|
const nextDate = '2000-01-02';
|
|
const utils = path.resolve(__dirname, '../../scripts/lib/utils.js');
|
|
fs.mkdirSync(bin);
|
|
// Only the calendar helper changes. Date.now and the actual lifecycle
|
|
// deadline stay real while the summarizer crosses into the next day.
|
|
fs.writeFileSync(calendarPreload, [
|
|
"const fs = require('node:fs');",
|
|
'require(process.env.ECC_SESSION_TEST_UTILS).getDateString = () =>',
|
|
' fs.existsSync(process.env.ECC_SESSION_TEST_STARTED)',
|
|
' ? process.env.ECC_SESSION_TEST_NEXT_DATE : process.env.ECC_SESSION_TEST_DATE;',
|
|
''
|
|
].join('\n'));
|
|
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Persist after a slow summary' }) + '\n');
|
|
fs.writeFileSync(summarizer, [
|
|
"const fs = require('node:fs');",
|
|
"fs.writeFileSync(process.env.ECC_SESSION_TEST_STARTED, 'started');",
|
|
'fs.writeFileSync(process.env.ECC_SESSION_TEST_CALENDAR_AFTER,',
|
|
' require(process.env.ECC_SESSION_TEST_UTILS).getDateString());',
|
|
"setTimeout(() => console.log('late summary'), 5000);",
|
|
''
|
|
].join('\n'));
|
|
fs.writeFileSync(path.join(bin, 'claude'), '#!/bin/sh\nexec "$ECC_SESSION_TEST_NODE" "$ECC_SESSION_TEST_SUMMARIZER"\n', { mode: 0o755 });
|
|
const root = path.resolve(__dirname, '../..');
|
|
const bootstrap = path.join(root, 'scripts/hooks/lifecycle-hook-bootstrap.js');
|
|
const res = spawnSync(process.execPath, [bootstrap, 'session:stop:session-end', 'scripts/hooks/session-end.js', 'minimal,standard,strict', '2500'], {
|
|
encoding: 'utf8', input: JSON.stringify({ transcript_path: transcript }),
|
|
env: {
|
|
...isolatedHomeEnv(home), PATH: bin,
|
|
NODE_OPTIONS: `--require ${JSON.stringify(calendarPreload)}`,
|
|
ECC_SESSION_TEST_NODE: process.execPath,
|
|
ECC_SESSION_TEST_SUMMARIZER: summarizer,
|
|
ECC_SESSION_TEST_UTILS: utils,
|
|
ECC_SESSION_TEST_STARTED: started,
|
|
ECC_SESSION_TEST_CALENDAR_AFTER: calendarAfter,
|
|
ECC_SESSION_TEST_DATE: sessionDate,
|
|
ECC_SESSION_TEST_NEXT_DATE: nextDate,
|
|
CLAUDE_PLUGIN_ROOT: root, ECC_HOOK_PROFILE: 'standard',
|
|
ECC_LLM_SUMMARY_INTERVAL: '1', ECC_SKIP_LLM_SUMMARY: '',
|
|
ECC_LLM_SUMMARY_SUBPROCESS: '', ECC_HOOK_DEADLINE_MS: '1',
|
|
ECC_DISABLED_HOOKS: '', ECC_DRY_RUN: '0', ECC_HOOKS_ENABLED: 'true'
|
|
},
|
|
timeout: 7000
|
|
});
|
|
assert.strictEqual(res.status, 0, res.stderr);
|
|
assert.ok(fs.existsSync(started), 'Fresh bootstrap must replace an inherited expired deadline');
|
|
assert.strictEqual(fs.readFileSync(calendarAfter, 'utf8'), nextDate, 'The summarizer must advance the fixture calendar');
|
|
assert.match(res.stderr, /LLM summary failed; falling back/);
|
|
assert.doesNotMatch(res.stderr, /lifecycle runner failed/);
|
|
const saved = fs.readFileSync(sessionFileFor(home, uuid, sessionDate), 'utf8');
|
|
assert.match(saved, /Persist after a slow summary/);
|
|
assert.ok(saved.includes(`**Date:** ${sessionDate}`), 'Resume metadata must retain the date captured before summarization');
|
|
assert.ok(!fs.existsSync(sessionFileFor(home, uuid, nextDate)), 'Rollover must not create a second session file');
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}) ? passed++ : failed++);
|
|
}
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|