import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearInternalHooks, createInternalHookEvent, registerInternalHook, triggerInternalHook, type InternalHookEvent, } from "./internal-hooks.js"; type ActionCase = { label: string; key: string; action: "received" | "transcribed" | "preprocessed" | "sent"; context: Record; assertContext: (context: Record) => void; }; const actionCases: ActionCase[] = [ { label: "message:received", key: "message:received", action: "received", context: { from: "signal:+15551234567", to: "bot:+15559876543", content: "Test message", channelId: "signal", conversationId: "conv-abc", messageId: "msg-xyz", senderId: "sender-1", senderName: "Test User", senderUsername: "testuser", senderE164: "+15551234567", provider: "signal", surface: "signal", threadId: "thread-1", originatingChannel: "signal", originatingTo: "bot:+15559876543", timestamp: 1707600000, }, assertContext: (context) => { expect(context.content).toBe("Test message"); expect(context.channelId).toBe("signal"); expect(context.senderE164).toBe("+15551234567"); expect(context.threadId).toBe("thread-1"); }, }, { label: "message:transcribed", key: "message:transcribed", action: "transcribed", context: { body: "🎤 [Audio]", bodyForAgent: "[Audio] Transcript: Hello from voice", transcript: "Hello from voice", channelId: "telegram", mediaType: "audio/ogg", }, assertContext: (context) => { expect(context.body).toBe("🎤 [Audio]"); expect(context.bodyForAgent).toContain("Transcript:"); expect(context.transcript).toBe("Hello from voice"); expect(context.mediaType).toBe("audio/ogg"); }, }, { label: "message:preprocessed", key: "message:preprocessed", action: "preprocessed", context: { body: "🎤 [Audio]", bodyForAgent: "[Audio] Transcript: Check https://example.com\n[Link summary: Example site]", transcript: "Check https://example.com", channelId: "telegram", mediaType: "audio/ogg", isGroup: false, }, assertContext: (context) => { expect(context.transcript).toBe("Check https://example.com"); expect(String(context.bodyForAgent)).toContain("Link summary"); expect(String(context.bodyForAgent)).toContain("Transcript:"); }, }, { label: "message:sent", key: "message:sent", action: "sent", context: { from: "bot:456", to: "user:123", content: "Reply text", channelId: "discord", conversationId: "channel:C123", provider: "discord", surface: "discord", threadId: "thread-abc", originatingChannel: "discord", originatingTo: "channel:C123", }, assertContext: (context) => { expect(context.content).toBe("Reply text"); expect(context.channelId).toBe("discord"); expect(context.conversationId).toBe("channel:C123"); expect(context.threadId).toBe("thread-abc"); }, }, ]; describe("message hooks", () => { beforeEach(() => { clearInternalHooks(); }); afterEach(() => { clearInternalHooks(); }); describe("action handlers", () => { for (const testCase of actionCases) { it(`triggers handler for ${testCase.label}`, async () => { const handler = vi.fn(); registerInternalHook(testCase.key, handler); await triggerInternalHook( createInternalHookEvent("message", testCase.action, "session-1", testCase.context), ); expect(handler).toHaveBeenCalledOnce(); const event = handler.mock.calls[0][0] as InternalHookEvent; expect(event.type).toBe("message"); expect(event.action).toBe(testCase.action); testCase.assertContext(event.context); }); } it("does not trigger action-specific handlers for other actions", async () => { const sentHandler = vi.fn(); registerInternalHook("message:sent", sentHandler); await triggerInternalHook( createInternalHookEvent("message", "received", "session-1", { content: "hello" }), ); expect(sentHandler).not.toHaveBeenCalled(); }); }); describe("general handler", () => { it("receives full message lifecycle in order", async () => { const events: InternalHookEvent[] = []; registerInternalHook("message", (event) => { events.push(event); }); const lifecycleFixtures: Array<{ action: "received" | "transcribed" | "preprocessed" | "sent"; context: Record; }> = [ { action: "received", context: { content: "hi" } }, { action: "transcribed", context: { transcript: "hello" } }, { action: "preprocessed", context: { body: "hello", bodyForAgent: "hello" } }, { action: "sent", context: { content: "reply" } }, ]; for (const fixture of lifecycleFixtures) { await triggerInternalHook( createInternalHookEvent("message", fixture.action, "s1", fixture.context), ); } expect(events.map((event) => event.action)).toEqual([ "received", "transcribed", "preprocessed", "sent", ]); }); it("triggers both general and specific handlers", async () => { const generalHandler = vi.fn(); const specificHandler = vi.fn(); registerInternalHook("message", generalHandler); registerInternalHook("message:received", specificHandler); await triggerInternalHook( createInternalHookEvent("message", "received", "s1", { content: "test" }), ); expect(generalHandler).toHaveBeenCalledOnce(); expect(specificHandler).toHaveBeenCalledOnce(); }); }); describe("error isolation", () => { it("does not propagate handler errors", async () => { const badHandler = vi.fn(() => { throw new Error("Hook exploded"); }); registerInternalHook("message:received", badHandler); await expect( triggerInternalHook( createInternalHookEvent("message", "received", "s1", { content: "test" }), ), ).resolves.not.toThrow(); expect(badHandler).toHaveBeenCalledOnce(); }); it("continues with later handlers when one fails", async () => { const failHandler = vi.fn(() => { throw new Error("First handler fails"); }); const successHandler = vi.fn(); registerInternalHook("message:received", failHandler); registerInternalHook("message:received", successHandler); await triggerInternalHook( createInternalHookEvent("message", "received", "s1", { content: "test" }), ); expect(failHandler).toHaveBeenCalledOnce(); expect(successHandler).toHaveBeenCalledOnce(); }); it("isolates async handler errors", async () => { const asyncFailHandler = vi.fn(async () => { throw new Error("Async hook failed"); }); registerInternalHook("message:sent", asyncFailHandler); await expect( triggerInternalHook(createInternalHookEvent("message", "sent", "s1", { content: "reply" })), ).resolves.not.toThrow(); expect(asyncFailHandler).toHaveBeenCalledOnce(); }); }); describe("event structure", () => { it("includes timestamps on message events", async () => { const handler = vi.fn(); registerInternalHook("message", handler); const before = new Date(); await triggerInternalHook( createInternalHookEvent("message", "received", "s1", { content: "hi" }), ); const after = new Date(); const event = handler.mock.calls[0][0] as InternalHookEvent; expect(event.timestamp).toBeInstanceOf(Date); expect(event.timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime()); expect(event.timestamp.getTime()).toBeLessThanOrEqual(after.getTime()); }); it("preserves mutable messages and sessionKey", async () => { const events: InternalHookEvent[] = []; registerInternalHook("message", (event) => { event.messages.push("Echo"); events.push(event); }); const sessionKey = "agent:main:telegram:abc"; const received = createInternalHookEvent("message", "received", sessionKey, { content: "hi", }); await triggerInternalHook(received); await triggerInternalHook( createInternalHookEvent("message", "sent", sessionKey, { content: "reply" }), ); expect(received.messages).toContain("Echo"); expect(events[0]?.sessionKey).toBe(sessionKey); expect(events[1]?.sessionKey).toBe(sessionKey); }); }); });