This commit is contained in:
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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user