moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,373 @@
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<Lifecycle, string | undefined> = {
'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<T>(fetchImpl: typeof globalThis.fetch, fn: () => Promise<T>): Promise<T> {
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<string, unknown>; 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);
});
});