| 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<string, unknown>; |
| assertContext: (context: Record<string, unknown>) => 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<string, unknown>; |
| }> = [ |
| { 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); |
| }); |
| }); |
| }); |
|
|