import { describe, expect, it } from 'vitest'; import { makeOpenclawHandler, makePreCompactHandler, makeSessionStartHandler, makeStopHandler, makeUserPromptSubmitHandler, type OpenclawHandlerInput, type PreCompactExtracted, type SessionStartExtracted, type StopExtracted, type UserPromptExtracted, } from '../src/handlers-core.js'; import type { EventAdapter, Lifecycle } from '../src/event-adapter.js'; import { HIT_FIXTURE, makeCtx, makeMockAdapter, makeMockBridge, withEnv } from './_helpers.js'; // ── make*Handler factories (stdin-JSON / exit-0 drive path) ───────────── describe('makeSessionStartHandler', () => { it('recalls personal-scoped frames and returns the adapter inject shape', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] }); const h = makeSessionStartHandler(a); const payload = h.parse({ cwd: '/proj', recall_limit: 7 }); expect(payload.recallLimit).toBe(7); const out = await h.run(payload, makeCtx(bridge)); expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 7, scope: 'personal', workspace: null }); // Mock adapter renames the inject seam to { additional_context }. expect(out).toMatchObject({ additional_context: expect.stringContaining('past observation') }); }); it('defaults recallLimit to opts.recallLimit then 20', () => { const a = makeMockAdapter(); expect(makeSessionStartHandler(a).parse({}).recallLimit).toBe(20); expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({}).recallLimit).toBe(5); // payload value wins over opts. expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({ recall_limit: 9 }).recallLimit).toBe(9); }); it('falls back to the CC hookSpecificOutput shape when the adapter has no formatInject', async () => { const a = makeMockAdapter({ formatInject: null, source: 'codex' }); const bridge = makeMockBridge({ recallMemoryHits: [] }); const out = (await makeSessionStartHandler(a).run({ cwd: '/p', sessionId: undefined, recallLimit: 20 }, makeCtx(bridge))) as { hookSpecificOutput: { hookEventName: string; source: string; additionalContext: string }; }; expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart'); expect(out.hookSpecificOutput.source).toBe('codex'); expect(out.hookSpecificOutput.additionalContext).toContain('no recalled frames'); }); }); describe('makeUserPromptSubmitHandler', () => { it('saves a temporary frame carrying the prompt', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge(); const h = makeUserPromptSubmitHandler(a); const payload = h.parse({ prompt: 'do the thing', cwd: '/proj', session_id: 's1' }); await h.run(payload, makeCtx(bridge)); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); const frame = bridge.saveMemory.mock.calls[0][0]; expect(frame.importance).toBe('temporary'); expect(frame.content).toBe('do the thing'); expect(frame.source).toBe(a.source); }); it('skips the save when the prompt is empty', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge(); const h = makeUserPromptSubmitHandler(a); await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge)); expect(bridge.saveMemory).not.toHaveBeenCalled(); }); }); describe('makeStopHandler', () => { it('summarizes the turn and saves an important frame', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge(); const h = makeStopHandler(a); const longResp = 'First sentence. ' + 'X'.repeat(2000) + '.'; await h.run(h.parse({ response: longResp, cwd: '/proj', session_id: 's2' }), makeCtx(bridge)); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); const frame = bridge.saveMemory.mock.calls[0][0]; expect(['important', 'critical']).toContain(frame.importance); expect(frame.content.length).toBeLessThanOrEqual(401); // budget + ellipsis }); it('promotes to critical on a "never" directive and attaches the parent frame id', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge(); const h = makeStopHandler(a); await h.run( h.parse({ response: 'never commit secrets to the public repo.', cwd: '/proj', parent_frame_id: 'frame-99' }), makeCtx(bridge), ); const frame = bridge.saveMemory.mock.calls[0][0]; expect(frame.importance).toBe('critical'); expect(frame.parent).toBe('frame-99'); }); it('skips the save when the response is empty', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge(); const h = makeStopHandler(a); await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge)); expect(bridge.saveMemory).not.toHaveBeenCalled(); }); }); describe('makePreCompactHandler', () => { it('calls cleanupFrames', async () => { const a = makeMockAdapter(); const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 3 } }); const h = makePreCompactHandler(a); await h.run(h.parse({ session_id: 's3' }), makeCtx(bridge)); expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1); }); }); // ── makeOpenclawHandler (in-process drive path) ───────────────────────── // OpenClaw adapter: eventName values are the native `type:action` keys. function openclawAdapter(): EventAdapter { const eventName: Record = { 'session-start': 'agent:bootstrap', 'user-prompt-submit': 'message:received', stop: 'message:sent', // HOOK.md uses the session-prefixed key; runtime action is 'compact:before'. 'pre-compact': 'session:compact:before', }; return makeMockAdapter({ source: 'openclaw', eventName, formatInject: null }); } describe('makeOpenclawHandler — lifecycle dispatch', () => { it('SessionStart (agent:bootstrap) recalls memory; consumer mutates bootstrapFiles', async () => { const a = openclawAdapter(); const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] }); const handler = makeOpenclawHandler(a); // The body returns the recalled/formatted text; the openclaw adapter is // responsible for pushing it onto event.context.bootstrapFiles. We model // that by capturing the recall and asserting the consumer can mutate. const bootstrapFiles: string[] = []; const input: OpenclawHandlerInput = { event: { type: 'agent', action: 'bootstrap', context: { bootstrapFiles } }, extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted, }; await handler.handle(input, makeCtx(bridge)); expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 20, scope: 'personal', workspace: null }); // Simulate the adapter's documented mutation seam working end-to-end. bootstrapFiles.push('recalled'); expect((input.event.context as { bootstrapFiles: string[] }).bootstrapFiles).toEqual(['recalled']); }); it('UserPromptSubmit (message:received) saves a temporary frame', async () => { const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'message', action: 'received' }, extracted: { prompt: 'hi there', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted, }; await handler.handle(input, makeCtx(bridge)); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); expect(bridge.saveMemory.mock.calls[0][0].importance).toBe('temporary'); }); it('Stop (message:sent) saves an important/critical frame', async () => { const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'message', action: 'sent' }, extracted: { cwd: '/proj', sessionId: 's1', response: 'we decided to ship it.', parent: undefined } as StopExtracted, }; await handler.handle(input, makeCtx(bridge)); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); expect(['important', 'critical']).toContain(bridge.saveMemory.mock.calls[0][0].importance); }); it('PreCompact matches on action "compact:before" (NOT the joined string) and cleans frames', async () => { const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'session', action: 'compact:before' }, extracted: { scope: 's1' } as PreCompactExtracted, }; await handler.handle(input, makeCtx(bridge)); expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1); }); it('ignores an event with no lifecycle match (no bridge calls)', async () => { const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'gateway', action: 'pre-restart' }, extracted: { scope: undefined } as PreCompactExtracted, }; await handler.handle(input, makeCtx(bridge)); expect(bridge.saveMemory).not.toHaveBeenCalled(); expect(bridge.recallMemory).not.toHaveBeenCalled(); expect(bridge.cleanupFrames).not.toHaveBeenCalled(); }); it('does NOT map an unrelated type whose action suffix collides (suffix-match is pre-compact-only)', async () => { // 'message:sent' is Stop's native key; a 'gateway:sent' event shares the // ':sent' suffix but must NOT route to Stop — the action-suffix fallback is // reserved for pre-compact's compact:before alias. const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a); await handler.handle( { event: { type: 'gateway', action: 'sent' }, extracted: { cwd: '/p', sessionId: 's1', response: 'x' } as StopExtracted }, makeCtx(bridge), ); expect(bridge.saveMemory).not.toHaveBeenCalled(); }); it('debounces message:sent — every dispatch resolves, only the last saves', async () => { const a = openclawAdapter(); const bridge = makeMockBridge(); const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 }); const mk = (response: string): OpenclawHandlerInput => ({ event: { type: 'message', action: 'sent', sessionKey: 'turn-1' }, extracted: { cwd: '/proj', sessionId: 's1', response, parent: undefined } as StopExtracted, }); // Fire three message:sent for the same turn and AWAIT ALL of them. Each // superseded dispatch MUST resolve (not hang) — the host awaits handlers // sequentially, so a leaked promise would block the host event loop // forever (fail-open invariant §7.3(1)). With the old code the first two // promises never settled and this Promise.all would time out. await Promise.all([ handler.handle(mk('first decided.'), makeCtx(bridge)), handler.handle(mk('second decided.'), makeCtx(bridge)), handler.handle(mk('third decided.'), makeCtx(bridge)), ]); // Last-writer-wins: exactly one save, carrying the final payload. expect(bridge.saveMemory).toHaveBeenCalledTimes(1); expect(bridge.saveMemory.mock.calls[0][0].content).toContain('third decided'); }); }); describe('makeOpenclawHandler — FAIL-OPEN (invariant §7.3(1))', () => { it('swallows a body error, resolves the promise, and never throws', async () => { const a = openclawAdapter(); const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') }); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'message', action: 'received' }, extracted: { prompt: 'will explode on save', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted, }; // Must resolve (not reject) despite the bridge throwing. await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined(); }); it('fails open when recallMemory rejects on SessionStart', async () => { const a = openclawAdapter(); const bridge = makeMockBridge({ recallMemoryThrows: new Error('boom') }); const handler = makeOpenclawHandler(a); const input: OpenclawHandlerInput = { event: { type: 'agent', action: 'bootstrap', context: {} }, extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted, }; await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined(); }); it('swallows a rejecting save in the DEBOUNCE branch (no unhandled rejection)', async () => { // The debounced Stop save fires in a detached timer AFTER dispatch's // try/catch returned, so a rejection there would escape as an unhandled // rejection unless explicitly caught. Regression guard for the openclaw // stopDebounceMs path (the only caller that sets it). const a = openclawAdapter(); const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') }); const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 }); const rejections: unknown[] = []; const onRej = (e: unknown): void => { rejections.push(e); }; process.on('unhandledRejection', onRej); try { await handler.handle( { event: { type: 'message', action: 'sent', sessionKey: 't1' }, extracted: { cwd: '/p', sessionId: 's1', response: 'final', parent: undefined } as StopExtracted }, makeCtx(bridge), ); // Flush any detached microtask carrying an unhandled rejection. await new Promise((r) => setTimeout(r, 20)); } finally { process.off('unhandledRejection', onRej); } expect(bridge.saveMemory).toHaveBeenCalledTimes(1); expect(rejections).toEqual([]); }); }); // ── Stop signal-emit opt-in (mirrors CC stop.ts Phase 1E) ─────────────── describe('makeStopHandler — WAGGLE_SIGNAL_EMIT opt-in', () => { function withCapturedFetch(fetchImpl: typeof globalThis.fetch, fn: () => Promise): Promise { const original = globalThis.fetch; globalThis.fetch = fetchImpl; return fn().finally(() => { globalThis.fetch = original; }); } function makeOkFetch(): typeof globalThis.fetch & { calls: Array<{ url: string; body: unknown }> } { const calls: Array<{ url: string; body: unknown }> = []; const impl = (async (url: string | URL | Request, init?: RequestInit) => { const body = init?.body ? JSON.parse(String(init.body)) : null; calls.push({ url: String(url), body }); return new Response(JSON.stringify({ dispatched: true, message: { id: 'srv-1' } }), { status: 201, headers: { 'content-type': 'application/json' }, }); }) as typeof globalThis.fetch & { calls: typeof calls }; impl.calls = calls; return impl; } it('does not emit when WAGGLE_SIGNAL_EMIT is unset', async () => { const a = makeMockAdapter({ source: 'cursor' }); const bridge = makeMockBridge(); const f = makeOkFetch(); await withEnv('WAGGLE_SIGNAL_EMIT', undefined, () => withCapturedFetch(f, () => makeStopHandler(a).run( makeStopHandler(a).parse({ response: 'never do that.', cwd: '/p' }), makeCtx(bridge), ), ), ); expect(f.calls).toHaveLength(0); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); }); it('emits a discovery signal on a critical turn when WAGGLE_SIGNAL_EMIT=1', async () => { const a = makeMockAdapter({ source: 'cursor' }); const bridge = makeMockBridge(); const f = makeOkFetch(); const h = makeStopHandler(a); await withEnv('WAGGLE_SIGNAL_EMIT', '1', () => withCapturedFetch(f, () => h.run(h.parse({ response: 'never commit secrets.', cwd: '/p', session_id: 'sc' }), makeCtx(bridge)), ), ); expect(f.calls).toHaveLength(1); const body = f.calls[0].body as { content: Record; senderId: string }; expect(body.senderId).toBe('cursor-hook'); expect(body.content.tool).toBe('cursor'); expect(body.content.importance).toBe('critical'); expect(body.content.frameId).toBe('frame-1'); expect(body.content.memoryWorkspace).toBe('personal'); expect(body.content.summary).toContain('never commit secrets'); }); it('still saves the frame even when the signal endpoint is unreachable (fail-open)', async () => { const a = makeMockAdapter({ source: 'cursor' }); const bridge = makeMockBridge(); const unreachable = (async () => { throw new Error('ECONNREFUSED'); }) as typeof globalThis.fetch; const h = makeStopHandler(a); await withEnv('WAGGLE_SIGNAL_EMIT', 'true', () => withCapturedFetch(unreachable, () => h.run(h.parse({ response: 'never commit secrets.', cwd: '/p' }), makeCtx(bridge)), ), ); expect(bridge.saveMemory).toHaveBeenCalledTimes(1); }); });