This commit is contained in:
82
packages/hive-mind-hooks-hermes/tests/adapter.test.ts
Normal file
82
packages/hive-mind-hooks-hermes/tests/adapter.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
HERMES_EVENT_NAME,
|
||||
HERMES_SESSION_START_OBSERVE_EVENT,
|
||||
hermesAdapter,
|
||||
} from '../src/adapter.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = join(HERE, '..', 'src');
|
||||
|
||||
describe('HERMES_EVENT_NAME (lifecycle → native event key)', () => {
|
||||
it('maps the three live lifecycles to snake_case Hermes events; pre-compact is undefined', () => {
|
||||
expect(HERMES_EVENT_NAME['session-start']).toBe('pre_llm_call');
|
||||
expect(HERMES_EVENT_NAME['user-prompt-submit']).toBe('pre_llm_call');
|
||||
expect(HERMES_EVENT_NAME['stop']).toBe('post_llm_call');
|
||||
// CONFIRMED ABSENT in source — Hermes ships no compaction hook.
|
||||
expect(HERMES_EVENT_NAME['pre-compact']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes the observer-only SessionStart key registered alongside pre_llm_call', () => {
|
||||
expect(HERMES_SESSION_START_OBSERVE_EVENT).toBe('on_session_start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hermesAdapter field extraction (reads from the `extra` envelope)', () => {
|
||||
const payload = {
|
||||
extra: {
|
||||
cwd: '/work/dir',
|
||||
session_id: 'sess-42',
|
||||
user_message: 'the user prompt',
|
||||
assistant_response: 'the assistant reply',
|
||||
parent_frame_id: 'frame-99',
|
||||
},
|
||||
};
|
||||
|
||||
it('source is hermes', () => {
|
||||
expect(hermesAdapter.source).toBe('hermes');
|
||||
});
|
||||
|
||||
it('extractCwd / extractSessionId / extractPrompt read from extra', () => {
|
||||
expect(hermesAdapter.extractCwd(payload)).toBe('/work/dir');
|
||||
expect(hermesAdapter.extractSessionId(payload)).toBe('sess-42');
|
||||
expect(hermesAdapter.extractPrompt(payload)).toBe('the user prompt');
|
||||
});
|
||||
|
||||
it('extractResponse / extractParent read from extra', async () => {
|
||||
expect(await hermesAdapter.extractResponse(payload, {})).toBe('the assistant reply');
|
||||
expect(hermesAdapter.extractParent(payload)).toBe('frame-99');
|
||||
});
|
||||
|
||||
it('falls back to top-level keys when the extra envelope is absent (forward-compat)', () => {
|
||||
const flat = { cwd: '/c', session_id: 's', user_message: 'p', parent_frame_id: 'pp' };
|
||||
expect(hermesAdapter.extractCwd(flat)).toBe('/c');
|
||||
expect(hermesAdapter.extractSessionId(flat)).toBe('s');
|
||||
expect(hermesAdapter.extractPrompt(flat)).toBe('p');
|
||||
expect(hermesAdapter.extractParent(flat)).toBe('pp');
|
||||
});
|
||||
|
||||
it('formatInject produces the { context } shape (appended to the user message)', () => {
|
||||
expect(hermesAdapter.formatInject?.('recalled frames here')).toEqual({ context: 'recalled frames here' });
|
||||
});
|
||||
|
||||
it('returns undefined for missing fields rather than throwing', () => {
|
||||
expect(hermesAdapter.extractCwd({})).toBeUndefined();
|
||||
expect(hermesAdapter.extractSessionId({})).toBeUndefined();
|
||||
expect(hermesAdapter.extractPrompt({})).toBeUndefined();
|
||||
expect(hermesAdapter.extractParent({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NO pre-compact entrypoint exists (structural invariant)', () => {
|
||||
it('ships exactly the three hook entrypoints — session-start / user-prompt-submit / stop', () => {
|
||||
expect(existsSync(join(SRC, 'hooks', 'session-start.ts'))).toBe(true);
|
||||
expect(existsSync(join(SRC, 'hooks', 'user-prompt-submit.ts'))).toBe(true);
|
||||
expect(existsSync(join(SRC, 'hooks', 'stop.ts'))).toBe(true);
|
||||
// There is genuinely nothing to hook for compaction — no entrypoint.
|
||||
expect(existsSync(join(SRC, 'hooks', 'pre-compact.ts'))).toBe(false);
|
||||
});
|
||||
});
|
||||
169
packages/hive-mind-hooks-hermes/tests/compact-on-stop.test.ts
Normal file
169
packages/hive-mind-hooks-hermes/tests/compact-on-stop.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
compactStatePath,
|
||||
isCompactEnabled,
|
||||
maybeCompactOnStop,
|
||||
readLastCompactTs,
|
||||
resolveWindowMs,
|
||||
writeLastCompactTs,
|
||||
} from '../src/compact-on-stop.js';
|
||||
import { makeMockBridge } from './hooks/_test-helpers.js';
|
||||
import type { HookContext } from '@waggle/hive-mind-hooks-core';
|
||||
import type { Logger } from '@waggle/hive-mind-shim-core';
|
||||
import type { MockBridge } from './hooks/_test-helpers.js';
|
||||
|
||||
const FLAG = 'WAGGLE_HERMES_COMPACT_ON_STOP';
|
||||
const WINDOW_ENV = 'WAGGLE_HERMES_COMPACT_WINDOW_MIN';
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
function makeLogger(): Logger {
|
||||
return {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
} as unknown as Logger;
|
||||
}
|
||||
|
||||
function makeCtx(bridge: MockBridge): HookContext {
|
||||
return { bridge, logger: makeLogger() };
|
||||
}
|
||||
|
||||
describe('compact-on-stop unit', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'hmher-compact-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// case 1
|
||||
it('flag off → cleanupFrames NOT called and no state file written', async () => {
|
||||
vi.stubEnv(FLAG, '');
|
||||
const bridge = makeMockBridge();
|
||||
await maybeCompactOnStop(makeCtx(bridge), { now: () => 1_000_000, home });
|
||||
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
|
||||
const last = await readLastCompactTs(compactStatePath(home));
|
||||
expect(last).toBeUndefined();
|
||||
});
|
||||
|
||||
// case 2
|
||||
it('flag on, no prior timestamp → cleanupFrames called once and state file holds now', async () => {
|
||||
vi.stubEnv(FLAG, '1');
|
||||
const bridge = makeMockBridge();
|
||||
const now = 5_000_000;
|
||||
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home });
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
const last = await readLastCompactTs(compactStatePath(home));
|
||||
expect(last).toBe(now);
|
||||
});
|
||||
|
||||
// case 3
|
||||
it('flag on, last = now - 1min, window 10min → NOT called (inside window)', async () => {
|
||||
vi.stubEnv(FLAG, '1');
|
||||
const bridge = makeMockBridge();
|
||||
const now = 10_000_000;
|
||||
await writeLastCompactTs(compactStatePath(home), now - 1 * MINUTE_MS);
|
||||
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS });
|
||||
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
|
||||
// timestamp untouched
|
||||
const last = await readLastCompactTs(compactStatePath(home));
|
||||
expect(last).toBe(now - 1 * MINUTE_MS);
|
||||
});
|
||||
|
||||
// case 4
|
||||
it('flag on, last = now - 11min, window 10min → called and timestamp updated to now', async () => {
|
||||
vi.stubEnv(FLAG, '1');
|
||||
const bridge = makeMockBridge();
|
||||
const now = 20_000_000;
|
||||
await writeLastCompactTs(compactStatePath(home), now - 11 * MINUTE_MS);
|
||||
await maybeCompactOnStop(makeCtx(bridge), { now: () => now, home, windowMs: 10 * MINUTE_MS });
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
const last = await readLastCompactTs(compactStatePath(home));
|
||||
expect(last).toBe(now);
|
||||
});
|
||||
|
||||
// case 5
|
||||
it('flag on, cleanupFrames rejects → resolves (no throw) and state file NOT updated', async () => {
|
||||
vi.stubEnv(FLAG, '1');
|
||||
const bridge = makeMockBridge();
|
||||
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
|
||||
const now = 30_000_000;
|
||||
await expect(
|
||||
maybeCompactOnStop(makeCtx(bridge), { now: () => now, home }),
|
||||
).resolves.toBeUndefined();
|
||||
const last = await readLastCompactTs(compactStatePath(home));
|
||||
expect(last).toBeUndefined();
|
||||
});
|
||||
|
||||
// case 6
|
||||
it('flag on, state-file write fails (home is a FILE) → resolves, no throw, cleanupFrames still attempted', async () => {
|
||||
vi.stubEnv(FLAG, '1');
|
||||
const homeFile = join(home, 'home-as-file');
|
||||
await writeFile(homeFile, 'not a dir');
|
||||
const bridge = makeMockBridge();
|
||||
const now = 40_000_000;
|
||||
await expect(
|
||||
maybeCompactOnStop(makeCtx(bridge), { now: () => now, home: homeFile }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// case 7
|
||||
it('WAGGLE_HERMES_COMPACT_WINDOW_MIN=5 honored; compactWindowMs opt overrides env', () => {
|
||||
vi.stubEnv(WINDOW_ENV, '5');
|
||||
expect(resolveWindowMs(process.env)).toBe(5 * MINUTE_MS);
|
||||
// opt override wins over env
|
||||
expect(resolveWindowMs(process.env, 2 * MINUTE_MS)).toBe(2 * MINUTE_MS);
|
||||
});
|
||||
|
||||
it('resolveWindowMs default is 10min when env unset/invalid', () => {
|
||||
expect(resolveWindowMs({})).toBe(600_000);
|
||||
expect(resolveWindowMs({ [WINDOW_ENV]: 'garbage' })).toBe(600_000);
|
||||
expect(resolveWindowMs({ [WINDOW_ENV]: '0' })).toBe(600_000);
|
||||
expect(resolveWindowMs({ [WINDOW_ENV]: '-3' })).toBe(600_000);
|
||||
});
|
||||
|
||||
// case 8
|
||||
it('isCompactEnabled truth table', () => {
|
||||
expect(isCompactEnabled({})).toBe(false);
|
||||
expect(isCompactEnabled({ [FLAG]: '' })).toBe(false);
|
||||
expect(isCompactEnabled({ [FLAG]: '0' })).toBe(false);
|
||||
expect(isCompactEnabled({ [FLAG]: 'false' })).toBe(false);
|
||||
expect(isCompactEnabled({ [FLAG]: 'FALSE' })).toBe(false);
|
||||
expect(isCompactEnabled({ [FLAG]: '1' })).toBe(true);
|
||||
expect(isCompactEnabled({ [FLAG]: 'true' })).toBe(true);
|
||||
expect(isCompactEnabled({ [FLAG]: 'yes' })).toBe(true);
|
||||
});
|
||||
|
||||
it('compactStatePath lives under the hermes dir', () => {
|
||||
const p = compactStatePath(home);
|
||||
expect(p).toBe(join(home, '.hermes', '.hive-mind-last-compact'));
|
||||
});
|
||||
|
||||
it('readLastCompactTs returns undefined on garbage content', async () => {
|
||||
const p = compactStatePath(home);
|
||||
await writeLastCompactTs(p, 123);
|
||||
// overwrite with garbage
|
||||
await writeFile(p, 'not-a-number');
|
||||
expect(await readLastCompactTs(p)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('readLastCompactTs returns undefined when file is missing', async () => {
|
||||
expect(await readLastCompactTs(join(home, 'does-not-exist'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writeLastCompactTs mkdirs the parent recursively then writes', async () => {
|
||||
const p = compactStatePath(home); // parent .hermes does not exist yet
|
||||
await writeLastCompactTs(p, 777);
|
||||
const raw = await readFile(p, 'utf-8');
|
||||
expect(raw).toBe('777');
|
||||
});
|
||||
});
|
||||
62
packages/hive-mind-hooks-hermes/tests/hooks/_test-helpers.ts
Normal file
62
packages/hive-mind-hooks-hermes/tests/hooks/_test-helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { CliBridge, MemoryHit } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
export interface MockBridgeOverrides {
|
||||
saveMemoryResult?: { id: string; success: boolean; workspace: string };
|
||||
recallMemoryHits?: MemoryHit[];
|
||||
cleanupFramesResult?: { pruned: number };
|
||||
saveMemoryThrows?: Error;
|
||||
}
|
||||
|
||||
export interface MockBridge extends CliBridge {
|
||||
saveMemory: ReturnType<typeof vi.fn>;
|
||||
recallMemory: ReturnType<typeof vi.fn>;
|
||||
cleanupFrames: ReturnType<typeof vi.fn>;
|
||||
callMcpTool: ReturnType<typeof vi.fn>;
|
||||
setWorkspaceById: ReturnType<typeof vi.fn>;
|
||||
getActiveWorkspaceId: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the cursor sibling test helper (tests/hooks/_test-helpers.ts). The
|
||||
* hermes hooks drive the SAME shared `runHook` contract, so the same
|
||||
* injectable CliBridge mock + stdout/exit captures apply unchanged.
|
||||
*/
|
||||
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
|
||||
let activeWorkspaceId: string | undefined;
|
||||
const saveMemory = overrides.saveMemoryThrows
|
||||
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
|
||||
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
|
||||
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
|
||||
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
|
||||
const callMcpTool = vi.fn(async () => ({}));
|
||||
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
|
||||
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
|
||||
return {
|
||||
saveMemory,
|
||||
recallMemory,
|
||||
cleanupFrames,
|
||||
callMcpTool,
|
||||
setWorkspaceById,
|
||||
getActiveWorkspaceId,
|
||||
} as unknown as MockBridge;
|
||||
}
|
||||
|
||||
export interface CapturedHookOutput {
|
||||
stdout: string[];
|
||||
exits: number[];
|
||||
}
|
||||
|
||||
export function makeHookCaptures(): CapturedHookOutput & {
|
||||
writeStdout: (s: string) => void;
|
||||
exit: (code: number) => void;
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const exits: number[] = [];
|
||||
return {
|
||||
stdout,
|
||||
exits,
|
||||
writeStdout: (s) => stdout.push(s),
|
||||
exit: (c) => exits.push(c),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runSessionStart } from '../../src/hooks/session-start.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { MemoryHit } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
const HIT_FIXTURE: MemoryHit = {
|
||||
id: 1,
|
||||
content: '[hm src:hermes event:stop] past observation',
|
||||
importance: 'important',
|
||||
source: 'system',
|
||||
score: 0.87,
|
||||
created_at: '2026-04-28T10:00:00.000Z',
|
||||
from: 'personal',
|
||||
};
|
||||
|
||||
describe('hermes session-start handler (split: on_session_start observer + pre_llm_call inject)', () => {
|
||||
it('INJECT path: recalls personal-scoped frames and emits { context } (hermes rename) when is_first_turn is absent', async () => {
|
||||
// The on_session_start observer payload omits is_first_turn → absence ⇒ inject.
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => JSON.stringify({ extra: { cwd: '/proj/x' }, recall_limit: 1 }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
|
||||
expect(cap.stdout).toHaveLength(1);
|
||||
const parsed = JSON.parse(cap.stdout[0]) as Record<string, unknown>;
|
||||
// Hermes appends pre_llm_call stdout { context } to the USER message (not the
|
||||
// system prompt — preserves the prefix cache). Assert the rename + that the
|
||||
// default CC hookSpecificOutput envelope is NOT used.
|
||||
expect(parsed['hookSpecificOutput']).toBeUndefined();
|
||||
expect(typeof parsed['context']).toBe('string');
|
||||
expect(parsed['context'] as string).toContain('past observation');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('INJECT path: explicit is_first_turn=true still injects { context }', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => JSON.stringify({ extra: { is_first_turn: true } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.recallMemory).toHaveBeenCalledTimes(1);
|
||||
const parsed = JSON.parse(cap.stdout[0]) as Record<string, unknown>;
|
||||
expect(typeof parsed['context']).toBe('string');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('GATING: is_first_turn=false → emits NO output, exits 0, never recalls', async () => {
|
||||
// On a non-first pre_llm_call the per-turn save is owned by user-prompt-submit;
|
||||
// session-start must do nothing (drain the pipe + exit 0).
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => JSON.stringify({ extra: { is_first_turn: false, user_message: 'turn 2' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.recallMemory).not.toHaveBeenCalled();
|
||||
expect(cap.stdout).toHaveLength(0);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('handles an empty recall result gracefully (still { context })', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const parsed = JSON.parse(cap.stdout[0]) as { context: string };
|
||||
expect(parsed.context).toContain('no recalled frames');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('annotates hits with their workspace origin when from != personal', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [{ ...HIT_FIXTURE, from: 'workspace:team-foo' }] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const parsed = JSON.parse(cap.stdout[0]) as { context: string };
|
||||
expect(parsed.context).toContain('workspace:team-foo');
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: exits 0 even when the bridge throws', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
bridge.recallMemory.mockRejectedValueOnce(new Error('cli unreachable'));
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: malformed stdin → no recall, exits 0', async () => {
|
||||
// safeJsonParse turns garbage into {} → absence of is_first_turn ⇒ inject path,
|
||||
// but the recall still runs against the empty payload and must exit 0.
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => 'not json at all {{{',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: a throwing stdin reader on the pre-gate path still exits 0', async () => {
|
||||
// The is_first_turn gate reads stdin BEFORE runHook; a rejecting reader
|
||||
// must not escape to the host (it would otherwise block the session).
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => { throw new Error('stdin exploded'); },
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
expect(bridge.recallMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
246
packages/hive-mind-hooks-hermes/tests/hooks/stop.test.ts
Normal file
246
packages/hive-mind-hooks-hermes/tests/hooks/stop.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runStop } from '../../src/hooks/stop.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { HookFrame } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
describe('hermes stop handler (post_llm_call — assistant_response in extra)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('summarizes the completed turn off extra.assistant_response and saves an important frame', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
extra: {
|
||||
assistant_response: 'Here is the answer to your question about X. It depends on the config.',
|
||||
cwd: '/proj/foo',
|
||||
conversation_id: 'conv-9',
|
||||
},
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.source).toBe('hermes');
|
||||
expect(frame.scope).toBe('conv-9');
|
||||
expect(['important', 'critical']).toContain(frame.importance);
|
||||
expect(frame.content.length).toBeGreaterThan(0);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('SAVE-ONLY: emits NO stdout (Hermes block/inject is stdout JSON, not exit codes)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'done', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.stdout).toHaveLength(0);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('TOLERATE-NULL: no assistant_response → no save, NO throw, exits 0', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { session_id: 'conv-2' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('reads the top-level response fallback when extra.assistant_response is absent', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ response: 'inline assistant message', session_id: 's' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('links the Stop frame to its parent prompt frame when known (extra.parent_frame_id)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
extra: {
|
||||
assistant_response: 'done with the task',
|
||||
parent_frame_id: 'frame-prompt-1',
|
||||
session_id: 's',
|
||||
},
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.parent).toBe('frame-prompt-1');
|
||||
});
|
||||
|
||||
it('does NOT emit a discovery signal by default (WAGGLE_SIGNAL_EMIT off)', async () => {
|
||||
vi.stubEnv('WAGGLE_SIGNAL_EMIT', '');
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'hi there', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.callMcpTool).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: exits 0 even when saveMemory rejects', async () => {
|
||||
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'some answer', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: malformed stdin → no save, exits 0', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => '%%% not json %%%',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hermes stop handler — opt-in compact-on-stop (OQ-4)', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'hmher-stop-compact-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// case 9
|
||||
it('DEFAULT-OFF: flag unset → save happens AND cleanupFrames NOT called', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
now: () => 1_000_000,
|
||||
home,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
// case 10
|
||||
it('flag on, eligible → save happens AND cleanupFrames called, save BEFORE cleanup', async () => {
|
||||
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
|
||||
const order: string[] = [];
|
||||
const bridge = makeMockBridge();
|
||||
bridge.saveMemory.mockImplementation(async () => {
|
||||
order.push('save');
|
||||
return { id: 'frame-1', success: true, workspace: 'personal' };
|
||||
});
|
||||
bridge.cleanupFrames.mockImplementation(async () => {
|
||||
order.push('cleanup');
|
||||
return { pruned: 0 };
|
||||
});
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
now: () => 2_000_000,
|
||||
home,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(['save', 'cleanup']);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
// case 11
|
||||
it('flag on, eligible, cleanupFrames rejects → exits 0 and saveMemory still called once', async () => {
|
||||
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
|
||||
const bridge = makeMockBridge();
|
||||
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
now: () => 3_000_000,
|
||||
home,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// case 12
|
||||
it('flag on, no assistant_response (no save) → cleanupFrames still gate-eligible and runs, exits 0', async () => {
|
||||
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
now: () => 4_000_000,
|
||||
home,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
// case 13 — save-before-compact ordering lock (flag ON)
|
||||
it('SAVE-FIRST: flag on but saveMemory rejects → cleanupFrames NOT called, exits 0', async () => {
|
||||
vi.stubEnv('WAGGLE_HERMES_COMPACT_ON_STOP', '1');
|
||||
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli fail') });
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ extra: { assistant_response: 'an answer', session_id: 's' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
now: () => 5_000_000,
|
||||
home,
|
||||
});
|
||||
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runUserPromptSubmit } from '../../src/hooks/user-prompt-submit.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { HookFrame } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
describe('hermes user-prompt-submit handler (SAVE-ONLY — pre_llm_call)', () => {
|
||||
it('saves a temporary, hermes-sourced frame containing the prompt from extra.user_message', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({
|
||||
extra: {
|
||||
user_message: 'How do I X?',
|
||||
cwd: '/proj/foo',
|
||||
conversation_id: 'conv-7',
|
||||
},
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame).toMatchObject({
|
||||
content: 'How do I X?',
|
||||
importance: 'temporary',
|
||||
scope: 'conv-7',
|
||||
source: 'hermes',
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('SAVE-ONLY: emits NO stdout (this hook cannot inject)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ extra: { user_message: 'hi', session_id: 's1' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
// The shared UserPromptSubmit body returns undefined → runHook writes nothing.
|
||||
expect(cap.stdout).toHaveLength(0);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('reads the top-level prompt fallback when extra.user_message is absent', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ prompt: 'top-level prompt', session_id: 's2' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.content).toBe('top-level prompt');
|
||||
expect(frame.source).toBe('hermes');
|
||||
});
|
||||
|
||||
it('skips the save when no prompt is present', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ extra: { session_id: 's3' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: exits 0 even if saveMemory rejects', async () => {
|
||||
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ extra: { user_message: 'x' } }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: malformed stdin → no save, exits 0', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => '<<<not json>>>',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
217
packages/hive-mind-hooks-hermes/tests/install.test.ts
Normal file
217
packages/hive-mind-hooks-hermes/tests/install.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { install } from '../src/install.js';
|
||||
import { HIVE_MIND_MARKER } from '../src/yaml-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
/** Hermes config.yaml is OPTIONAL — `initial=undefined` exercises create-if-missing. */
|
||||
async function bootstrap(initialYaml: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmher-install-'));
|
||||
const hermesDir = join(home, '.hermes');
|
||||
await mkdir(hermesDir, { recursive: true });
|
||||
const configPath = join(hermesDir, 'config.yaml');
|
||||
if (initialYaml !== undefined) {
|
||||
await writeFile(configPath, initialYaml, 'utf-8');
|
||||
}
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, configPath, pointerPath: join(hermesDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
function readPointer(p: string): Promise<Record<string, unknown>> {
|
||||
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function hooksOf(config: Record<string, unknown>): Record<string, Array<Record<string, unknown>>> {
|
||||
return config['hooks'] as Record<string, Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
describe('install (hermes)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws on malformed YAML in an existing config.yaml', async () => {
|
||||
env = await bootstrap('hooks:\n\t- : : :\n bad');
|
||||
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/parse/i);
|
||||
});
|
||||
|
||||
// ── pre-existed branch ────────────────────────────────────────────────
|
||||
|
||||
it('writes a LITERAL byte-identical backup before mutating a pre-existing config.yaml', async () => {
|
||||
// Comments + ordering that a YAML round-trip would NOT preserve — proves
|
||||
// the backup is the original bytes, not a re-serialized merge.
|
||||
const initial = '# my hermes config\nmodel: opus\nhooks:\n pre_llm_call:\n - command: node /existing/x.js\n timeout: 10\n';
|
||||
env = await bootstrap(initial);
|
||||
const original = await readFile(env.configPath, 'utf-8');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
const backupContent = await readFile(result.backupPath as string, 'utf-8');
|
||||
expect(backupContent).toBe(original);
|
||||
// The backup preserves the comment that YAML re-serialization drops.
|
||||
expect(backupContent).toContain('# my hermes config');
|
||||
});
|
||||
|
||||
it('records created_by_us=false + settings_backup when config.yaml pre-existed', async () => {
|
||||
env = await bootstrap('model: opus\nhooks: {}\n');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.createdByUs).toBe(false);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(false);
|
||||
expect(pointer['settings_backup']).toBe(result.backupPath);
|
||||
});
|
||||
|
||||
it('additively merges hive entries + preserves the user hook entry + user top-level keys', async () => {
|
||||
const initial = 'model: opus\nhooks:\n pre_llm_call:\n - command: node /existing/x.js\n timeout: 10\n';
|
||||
env = await bootstrap(initial);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hooks = hooksOf(after);
|
||||
// pre_llm_call now holds the user lint hook + 2 hive entries (session-start inject + user-prompt).
|
||||
expect(hooks['pre_llm_call']).toHaveLength(3);
|
||||
expect(hooks['pre_llm_call'][0]['command']).toBe('node /existing/x.js');
|
||||
expect(hooks['pre_llm_call'][0]['_hive_mind']).toBeUndefined();
|
||||
const hiveEntries = hooks['pre_llm_call'].filter((e) => e['_hive_mind'] === HIVE_MIND_MARKER);
|
||||
expect(hiveEntries).toHaveLength(2);
|
||||
// The split SessionStart observer + the Stop entry are present.
|
||||
expect(hooks['on_session_start']).toHaveLength(1);
|
||||
expect(hooks['post_llm_call']).toHaveLength(1);
|
||||
// User top-level key preserved.
|
||||
expect(after['model']).toBe('opus');
|
||||
});
|
||||
|
||||
// ── create-if-missing branch ──────────────────────────────────────────
|
||||
|
||||
it('creates config.yaml with the hive hooks when it is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hooks = hooksOf(after);
|
||||
expect(Object.keys(hooks).sort()).toEqual(['on_session_start', 'post_llm_call', 'pre_llm_call']);
|
||||
expect(result.createdByUs).toBe(true);
|
||||
});
|
||||
|
||||
it('records created_by_us=true and writes NO backup when config.yaml is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.backupPath).toBeNull();
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
});
|
||||
|
||||
// ── auto-accept consent allow-list ────────────────────────────────────
|
||||
|
||||
it('seeds hooks_auto_accept: true by default (headless consent allow-list)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.autoAcceptSeeded).toBe(true);
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(after['hooks_auto_accept']).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT seed auto-accept when autoAccept=false (--no-auto-accept)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir, autoAccept: false });
|
||||
expect(result.autoAcceptSeeded).toBe(false);
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(after['hooks_auto_accept']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not re-seed auto-accept when the user already set it true', async () => {
|
||||
env = await bootstrap('hooks_auto_accept: true\nhooks: {}\n');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.autoAcceptSeeded).toBe(false);
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(after['hooks_auto_accept']).toBe(true);
|
||||
});
|
||||
|
||||
// ── pointer + events + cli-path ───────────────────────────────────────
|
||||
|
||||
it('drops a pointer file with installed_hooks (3, no pre-compact) + registered_events', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(result.pointerPath)).toBe(true);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop']);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
const extra = pointer['extra'] as Record<string, unknown>;
|
||||
// Dedup'd native event keys across the four register entries.
|
||||
expect((extra['registered_events'] as string[]).sort()).toEqual([
|
||||
'on_session_start',
|
||||
'post_llm_call',
|
||||
'pre_llm_call',
|
||||
]);
|
||||
expect(extra['auto_accept_seeded']).toBe(true);
|
||||
});
|
||||
|
||||
it('result.registeredEvents reports the 3 unique native keys', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect([...result.registeredEvents].sort()).toEqual([
|
||||
'on_session_start',
|
||||
'post_llm_call',
|
||||
'pre_llm_call',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps the per-hook timeout to the 300s hard cap', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir, hookTimeoutSeconds: 9999 });
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hooks = hooksOf(after);
|
||||
expect(hooks['post_llm_call'][0]['timeout']).toBe(300);
|
||||
});
|
||||
|
||||
it('respects a custom now() for a deterministic backup filename', async () => {
|
||||
env = await bootstrap('hooks: {}\n');
|
||||
const fixedTs = '2026-04-28T10:30:45.123Z';
|
||||
const result = await install({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
now: () => new Date(fixedTs),
|
||||
});
|
||||
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
const stats = await stat(result.backupPath as string);
|
||||
expect(stats.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('threads --cli-path into every generated hook command + records it in the pointer', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const cliPath = '/abs/path/to/dist/index.js';
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
|
||||
expect(result.cliPath).toBe(cliPath);
|
||||
|
||||
const after = parseYaml(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hooks = hooksOf(after);
|
||||
expect(hooks['post_llm_call'][0]['command']).toContain(`--cli-path "${cliPath}"`);
|
||||
expect(hooks['on_session_start'][0]['command']).toContain(`--cli-path "${cliPath}"`);
|
||||
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
|
||||
it('rejects --cli-path values containing double-quote characters', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await expect(install({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
cliPath: 'malicious" && rm -rf / "',
|
||||
})).rejects.toThrow(/double-quote/);
|
||||
});
|
||||
});
|
||||
76
packages/hive-mind-hooks-hermes/tests/paths.test.ts
Normal file
76
packages/hive-mind-hooks-hermes/tests/paths.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
resolvePaths,
|
||||
} from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (hermes)', () => {
|
||||
it('places config.yaml + pointer under <home>/.hermes/', () => {
|
||||
const home = resolve('/fake/home');
|
||||
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
|
||||
expect(paths.hermesDir).toBe(join(home, '.hermes'));
|
||||
// Hermes targets the SHELL-HOOKS config.yaml (top-level `hooks:` block) —
|
||||
// NOT the gateway dir-hooks (~/.hermes/hooks/<name>/) nor plugin hooks.
|
||||
expect(paths.configPath).toBe(join(home, '.hermes', 'config.yaml'));
|
||||
expect(paths.pointerPath).toBe(join(home, '.hermes', 'hive-mind-install.json'));
|
||||
});
|
||||
|
||||
it('hooksDir override wins over moduleUrl', () => {
|
||||
const explicit = resolve('/x/y/hooks');
|
||||
const paths = resolvePaths({
|
||||
home: resolve('/h'),
|
||||
hooksDir: explicit,
|
||||
moduleUrl: 'file:///irrelevant/dist/install.js',
|
||||
});
|
||||
expect(paths.hooksDir).toBe(explicit);
|
||||
});
|
||||
|
||||
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
|
||||
const paths = resolvePaths({ home: resolve('/h') });
|
||||
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hookCommandFor (hermes, 2-arg shared helper)', () => {
|
||||
it('produces a quoted node invocation around the absolute script path', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
|
||||
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
|
||||
});
|
||||
|
||||
it('appends --cli-path when supplied', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '/abs/cli/dist/index.js');
|
||||
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
|
||||
});
|
||||
|
||||
it('omits --cli-path when empty string is passed', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '');
|
||||
expect(cmd).not.toContain('--cli-path');
|
||||
});
|
||||
|
||||
it('preserves Windows-style paths (with spaces) inside the quotes', () => {
|
||||
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', 'C:\\Program Files\\hive-mind\\dist\\index.js');
|
||||
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupPathFor (hermes)', () => {
|
||||
it('replaces colons and dots in the timestamp for filesystem safety', () => {
|
||||
const backup = backupPathFor('/h/.hermes/config.yaml', '2026-04-28T10:30:45.123Z');
|
||||
expect(backup).toBe('/h/.hermes/config.yaml.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allHookBasenames (hermes — THREE hooks only, NO pre-compact)', () => {
|
||||
it('returns exactly the three canonical basenames (Hermes ships no compaction hook)', () => {
|
||||
expect([...allHookBasenames()].sort()).toEqual([
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
// Explicit: pre-compact is intentionally absent.
|
||||
expect([...allHookBasenames()]).not.toContain('pre-compact');
|
||||
});
|
||||
});
|
||||
225
packages/hive-mind-hooks-hermes/tests/register.test.ts
Normal file
225
packages/hive-mind-hooks-hermes/tests/register.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import {
|
||||
HIVE_MIND_MARKER,
|
||||
HOOKS_KEY,
|
||||
hasHiveEntries,
|
||||
isHiveEntry,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
yamlRegister,
|
||||
yamlUnregister,
|
||||
type HermesRegisterEntry,
|
||||
} from '../src/yaml-merger.js';
|
||||
|
||||
function entry(eventKey: string, command: string, timeout = 60): HermesRegisterEntry {
|
||||
return { eventKey, command, timeout };
|
||||
}
|
||||
|
||||
/** The four register entries hermes install builds (SessionStart is 2-key). */
|
||||
const ALL_ENTRIES: readonly HermesRegisterEntry[] = [
|
||||
entry('on_session_start', 'node "/abs/dist/hooks/session-start.js"'),
|
||||
entry('pre_llm_call', 'node "/abs/dist/hooks/session-start.js"'),
|
||||
entry('pre_llm_call', 'node "/abs/dist/hooks/user-prompt-submit.js"'),
|
||||
entry('post_llm_call', 'node "/abs/dist/hooks/stop.js"'),
|
||||
];
|
||||
|
||||
function eventArray(config: Record<string, unknown>, eventKey: string): Record<string, unknown>[] {
|
||||
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
|
||||
return (hooks?.[eventKey] as Record<string, unknown>[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
describe('parseConfig (hermes YAML codec)', () => {
|
||||
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
|
||||
expect(parseConfig('')).toEqual({});
|
||||
expect(parseConfig(' \n ')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses a realistic config.yaml WITH comments + a pre-existing user hook', () => {
|
||||
const raw = [
|
||||
'# Hermes CLI config',
|
||||
'model: claude-opus',
|
||||
'hooks:',
|
||||
' # a user-installed lint hook',
|
||||
' pre_llm_call:',
|
||||
' - command: node /user/own/lint.js',
|
||||
' timeout: 30',
|
||||
'hooks_auto_accept: false',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseConfig(raw);
|
||||
expect(parsed['model']).toBe('claude-opus');
|
||||
expect(parsed['hooks_auto_accept']).toBe(false);
|
||||
const arr = eventArray(parsed, 'pre_llm_call');
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0]['command']).toBe('node /user/own/lint.js');
|
||||
expect(arr[0]['timeout']).toBe(30);
|
||||
});
|
||||
|
||||
it('throws on malformed YAML so the installer fails loudly', () => {
|
||||
// A block-mapping value that cannot parse (tab indentation / bad structure).
|
||||
expect(() => parseConfig('hooks:\n\t- : : :\n bad')).toThrow(/parse/i);
|
||||
});
|
||||
|
||||
it('treats a top-level scalar/array YAML doc as empty (not a crash)', () => {
|
||||
expect(parseConfig('"just a string"')).toEqual({});
|
||||
expect(parseConfig('- a\n- b\n')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeConfig (hermes YAML codec)', () => {
|
||||
it('round-trips a merged config back to parseable YAML with a trailing newline', () => {
|
||||
const merged = yamlRegister({ model: 'x' }, [entry('post_llm_call', 'node /a/stop.js')]);
|
||||
const text = serializeConfig(merged);
|
||||
expect(text.endsWith('\n')).toBe(true);
|
||||
const reparsed = parseYaml(text) as Record<string, unknown>;
|
||||
expect(reparsed['model']).toBe('x');
|
||||
expect(eventArray(reparsed, 'post_llm_call')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('yamlRegister (hermes flat {command,timeout,_hive_mind} entry shape)', () => {
|
||||
it('returns a NEW object — does not mutate input (immutability contract)', () => {
|
||||
const original: Record<string, unknown> = { hooks: { pre_llm_call: [] } };
|
||||
const merged = yamlRegister(original, [entry('pre_llm_call', 'node /a/x.js')]);
|
||||
expect(merged).not.toBe(original);
|
||||
// Input untouched.
|
||||
expect((original['hooks'] as Record<string, unknown>)['pre_llm_call']).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers a marker-tagged entry under each supplied native event key', () => {
|
||||
const merged = yamlRegister({}, ALL_ENTRIES);
|
||||
expect(eventArray(merged, 'on_session_start')).toHaveLength(1);
|
||||
// pre_llm_call carries BOTH the session-start inject hook and the user-prompt hook.
|
||||
expect(eventArray(merged, 'pre_llm_call')).toHaveLength(2);
|
||||
expect(eventArray(merged, 'post_llm_call')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds the flat entry shape: {command, timeout, _hive_mind marker}', () => {
|
||||
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js', 9)]);
|
||||
const e = eventArray(merged, 'post_llm_call')[0];
|
||||
expect(e['command']).toBe('node /a/stop.js');
|
||||
expect(e['timeout']).toBe(9);
|
||||
expect(e['_hive_mind']).toBe(HIVE_MIND_MARKER);
|
||||
// No nested matcher/hooks wrapper — matcher is stripped-with-warning on
|
||||
// lifecycle events, so we never set it.
|
||||
expect(e['matcher']).toBeUndefined();
|
||||
expect(e['hooks']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves existing (user) hook entries verbatim — additive merge', () => {
|
||||
const existing: Record<string, unknown> = {
|
||||
hooks: {
|
||||
pre_llm_call: [
|
||||
{ command: 'node /user/own.js', timeout: 30 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const merged = yamlRegister(existing, [entry('pre_llm_call', 'node /a/session-start.js')]);
|
||||
const arr = eventArray(merged, 'pre_llm_call');
|
||||
expect(arr).toHaveLength(2);
|
||||
expect(arr[0]['command']).toBe('node /user/own.js');
|
||||
expect(arr[0]['_hive_mind']).toBeUndefined();
|
||||
expect(arr[1]['_hive_mind']).toBe(HIVE_MIND_MARKER);
|
||||
});
|
||||
|
||||
it('preserves unrelated top-level (non-hooks) YAML keys', () => {
|
||||
const merged = yamlRegister(
|
||||
{ model: 'claude-opus', temperature: 0.2, hooks: {} },
|
||||
[entry('pre_llm_call', 'node /a/x.js')],
|
||||
);
|
||||
expect(merged['model']).toBe('claude-opus');
|
||||
expect(merged['temperature']).toBe(0.2);
|
||||
});
|
||||
|
||||
it('replaces our own marker-tagged entry on re-install (dedup by command, in place)', () => {
|
||||
const e = entry('pre_llm_call', 'node /a/session-start.js', 60);
|
||||
const merged1 = yamlRegister({}, [e]);
|
||||
const merged2 = yamlRegister(merged1, [{ ...e, timeout: 120 }]);
|
||||
const arr = eventArray(merged2, 'pre_llm_call');
|
||||
expect(arr).toHaveLength(1); // never duplicated
|
||||
expect(arr[0]['timeout']).toBe(120);
|
||||
expect(arr[0]['_hive_mind']).toBe(HIVE_MIND_MARKER);
|
||||
});
|
||||
|
||||
it('re-registering the full set keeps each pre_llm_call slot at exactly 2 hive entries', () => {
|
||||
const merged1 = yamlRegister({}, ALL_ENTRIES);
|
||||
const merged2 = yamlRegister(merged1, ALL_ENTRIES);
|
||||
expect(eventArray(merged2, 'pre_llm_call')).toHaveLength(2);
|
||||
expect(eventArray(merged2, 'on_session_start')).toHaveLength(1);
|
||||
expect(eventArray(merged2, 'post_llm_call')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not collapse two DIFFERENT hive commands sharing one event key', () => {
|
||||
// session-start inject + user-prompt both ride pre_llm_call with distinct
|
||||
// commands — they must coexist, not dedup each other.
|
||||
const merged = yamlRegister({}, [
|
||||
entry('pre_llm_call', 'node /a/session-start.js'),
|
||||
entry('pre_llm_call', 'node /a/user-prompt-submit.js'),
|
||||
]);
|
||||
const arr = eventArray(merged, 'pre_llm_call');
|
||||
expect(arr).toHaveLength(2);
|
||||
expect(arr.map((e) => e['command']).sort()).toEqual([
|
||||
'node /a/session-start.js',
|
||||
'node /a/user-prompt-submit.js',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHiveEntry (hermes structural marker)', () => {
|
||||
it('true only for entries carrying the structural _hive_mind sentinel', () => {
|
||||
expect(isHiveEntry({ command: 'x', _hive_mind: HIVE_MIND_MARKER })).toBe(true);
|
||||
expect(isHiveEntry({ command: 'x' })).toBe(false);
|
||||
expect(isHiveEntry({ command: 'x', _hive_mind: 'someone-else' })).toBe(false);
|
||||
expect(isHiveEntry(undefined)).toBe(false);
|
||||
expect(isHiveEntry('not-an-object')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHiveEntries (hermes)', () => {
|
||||
it('false on empty / hookless config', () => {
|
||||
expect(hasHiveEntries(undefined)).toBe(false);
|
||||
expect(hasHiveEntries({})).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('true once a marker-tagged entry is present', () => {
|
||||
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]);
|
||||
expect(hasHiveEntries(merged)).toBe(true);
|
||||
});
|
||||
|
||||
it('false for a config holding ONLY non-hive (user) entries', () => {
|
||||
const userOnly: Record<string, unknown> = {
|
||||
hooks: { post_llm_call: [{ command: 'node /user/own.js', timeout: 5 }] },
|
||||
};
|
||||
expect(hasHiveEntries(userOnly)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('yamlUnregister (hermes — used for diagnostics / backup-less path)', () => {
|
||||
it('strips exactly our marker-tagged entries, preserves user entries', () => {
|
||||
const userEntry = { command: 'node /user/own.js', timeout: 5 };
|
||||
const withUser: Record<string, unknown> = { hooks: { post_llm_call: [userEntry] } };
|
||||
const merged = yamlRegister(withUser, [entry('post_llm_call', 'node /a/stop.js')]);
|
||||
expect(eventArray(merged, 'post_llm_call')).toHaveLength(2);
|
||||
|
||||
const stripped = yamlUnregister(merged);
|
||||
const arr = eventArray(stripped, 'post_llm_call');
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0]['command']).toBe('node /user/own.js');
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns a NEW object and leaves the input untouched (immutability)', () => {
|
||||
const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]);
|
||||
const stripped = yamlUnregister(merged);
|
||||
expect(stripped).not.toBe(merged);
|
||||
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
|
||||
});
|
||||
|
||||
it('is a no-op (new object) when there is no hooks block', () => {
|
||||
const stripped = yamlUnregister({ model: 'x' });
|
||||
expect(stripped['model']).toBe('x');
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
});
|
||||
});
|
||||
130
packages/hive-mind-hooks-hermes/tests/uninstall.test.ts
Normal file
130
packages/hive-mind-hooks-hermes/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initialYaml: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmher-uninstall-'));
|
||||
const hermesDir = join(home, '.hermes');
|
||||
await mkdir(hermesDir, { recursive: true });
|
||||
const configPath = join(hermesDir, 'config.yaml');
|
||||
if (initialYaml !== undefined) {
|
||||
await writeFile(configPath, initialYaml, 'utf-8');
|
||||
}
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, configPath, pointerPath: join(hermesDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
describe('uninstall (hermes)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws when no pointer file exists', async () => {
|
||||
env = await bootstrap('hooks: {}\n');
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/pointer/);
|
||||
});
|
||||
|
||||
it('throws when the pointer is malformed', async () => {
|
||||
env = await bootstrap('hooks: {}\n');
|
||||
await writeFile(env.pointerPath, '{}', 'utf-8');
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
|
||||
// YAML round-trip is lossy (comments + ordering are dropped on re-serialize),
|
||||
// so reversibility relies on restoring the ORIGINAL BYTES from the backup.
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
|
||||
// Deliberately include comments + non-alphabetical key ordering that a
|
||||
// naive YAML re-serialize would NOT reproduce.
|
||||
const initial = [
|
||||
'# Hermes config — hand-edited, comments matter',
|
||||
'model: claude-opus # the good one',
|
||||
'temperature: 0.2',
|
||||
'hooks:',
|
||||
' pre_llm_call:',
|
||||
' - command: node /existing/ctx.js',
|
||||
' timeout: 10',
|
||||
' post_llm_call:',
|
||||
' - command: node /existing/turn.js',
|
||||
' timeout: 10',
|
||||
'',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
const preInstall = await readFile(env.configPath, 'utf-8');
|
||||
const preHash = sha256(preInstall);
|
||||
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterInstall = await readFile(env.configPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
|
||||
// Sanity: the merged write IS lossy — the comment is gone post-install,
|
||||
// which is exactly why we need the literal backup to reverse it.
|
||||
expect(afterInstall).not.toContain('# Hermes config');
|
||||
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.createdRemoved).toBe(false);
|
||||
expect(u.restoredFrom).not.toBeNull();
|
||||
const afterUninstall = await readFile(env.configPath, 'utf-8');
|
||||
// Byte-for-byte identical — the comment + ordering are back.
|
||||
expect(sha256(afterUninstall)).toBe(preHash);
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
expect(afterUninstall).toContain('# Hermes config — hand-edited, comments matter');
|
||||
});
|
||||
|
||||
it('removes backup + pointer by default after a restore', async () => {
|
||||
env = await bootstrap('model: opus\nhooks: {}\n');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.backupRemoved).toBe(true);
|
||||
expect(existsSync(result.backupPath as string)).toBe(false);
|
||||
expect(existsSync(result.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the backup when cleanupBackup=false', async () => {
|
||||
env = await bootstrap('model: opus\nhooks: {}\n');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir, cleanupBackup: false });
|
||||
expect(u.backupRemoved).toBe(false);
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
});
|
||||
|
||||
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
|
||||
|
||||
it('deletes the config.yaml we created and leaves NO orphan (absent → install → uninstall)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.createdRemoved).toBe(true);
|
||||
expect(u.restoredFrom).toBeNull();
|
||||
// No orphaned config, no leftover pointer.
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
expect(existsSync(env.pointerPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
187
packages/hive-mind-hooks-hermes/tests/verify.test.ts
Normal file
187
packages/hive-mind-hooks-hermes/tests/verify.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { install } from '../src/install.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
hermesDir: string;
|
||||
}
|
||||
|
||||
async function bootstrap(
|
||||
initialYaml: string | undefined,
|
||||
withHookFiles: boolean,
|
||||
): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmher-verify-'));
|
||||
const hermesDir = join(home, '.hermes');
|
||||
await mkdir(hermesDir, { recursive: true });
|
||||
if (initialYaml !== undefined) {
|
||||
await writeFile(join(hermesDir, 'config.yaml'), initialYaml, 'utf-8');
|
||||
}
|
||||
const hooksDir = join(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
if (withHookFiles) {
|
||||
// THREE scripts only — Hermes ships no pre-compact hook.
|
||||
for (const b of ['session-start', 'user-prompt-submit', 'stop']) {
|
||||
await writeFile(join(hooksDir, `${b}.js`), '/* mock hook */', 'utf-8');
|
||||
}
|
||||
}
|
||||
return { home, hooksDir, hermesDir };
|
||||
}
|
||||
|
||||
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
|
||||
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
|
||||
const emitter = new EventEmitter();
|
||||
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
|
||||
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
|
||||
const child = Object.assign(emitter, {
|
||||
stdout,
|
||||
stderr,
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
setImmediate(() => emitter.emit('exit', opts.exitCode));
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
}
|
||||
|
||||
describe('verify (hermes)', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports failure when config.yaml is missing', async () => {
|
||||
const env = await bootstrap(undefined, true);
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks[0].name).toBe('config.yaml exists');
|
||||
expect(result.checks[0].ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failure when hooks are not yet installed', async () => {
|
||||
const env = await bootstrap('model: opus\nhooks: {}\n', true);
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind hook entries'))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after install with the 3 hook files on disk and CLI reachable', async () => {
|
||||
const env = await bootstrap('model: opus\nhooks: {}\n', true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
|
||||
expect(cliCheck?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'config.yaml contains hive-mind hook entries')?.ok).toBe(true);
|
||||
const diskChecks = result.checks.filter((c) => c.name.includes('readable on disk'));
|
||||
expect(diskChecks).toHaveLength(3); // exactly 3 — no pre-compact
|
||||
expect(diskChecks.every((c) => c.ok)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap('hooks: {}\n', true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
|
||||
expect(cliCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('flags missing hook script files even when the config entry is present', async () => {
|
||||
const env = await bootstrap('hooks: {}\n', false); // no hook .js files
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
|
||||
expect(fileCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
|
||||
const env = await bootstrap('hooks: {}\n', true);
|
||||
envs.push(env);
|
||||
const cliPath = '/abs/from/pointer.js';
|
||||
await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
|
||||
|
||||
const records: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
|
||||
records.push({ command: cmd, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
|
||||
}) as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const probeRecord = records[records.length - 1];
|
||||
expect(probeRecord.command).toBe(process.execPath);
|
||||
expect(probeRecord.args[0]).toBe(cliPath);
|
||||
expect(probeRecord.args[1]).toBe('--help');
|
||||
});
|
||||
|
||||
// ── hermes-specific surfacing: headless consent / registration ────────
|
||||
|
||||
it('surfaces the headless-consent check as PASS when auto-accept is seeded (default install)', async () => {
|
||||
const env = await bootstrap('hooks: {}\n', true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir }); // auto-accept seeded by default
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const consent = result.checks.find((c) => c.name.toLowerCase().includes('consent'));
|
||||
expect(consent).toBeDefined();
|
||||
expect(consent?.ok).toBe(true);
|
||||
expect(consent?.detail?.toLowerCase()).toContain('hooks_auto_accept');
|
||||
});
|
||||
|
||||
it('FAILS the headless-consent check + overall ok when auto-accept was NOT seeded', async () => {
|
||||
const env = await bootstrap('hooks: {}\n', true);
|
||||
envs.push(env);
|
||||
// --no-auto-accept: hooks silently never register under a headless launch.
|
||||
await install({ home: env.home, hooksDir: env.hooksDir, autoAccept: false });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const consent = result.checks.find((c) => c.name.toLowerCase().includes('consent'));
|
||||
expect(consent?.ok).toBe(false);
|
||||
expect(consent?.detail?.toLowerCase()).toContain('hermes_accept_hooks=1');
|
||||
// The advisory failing drags overall ok to false.
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user