diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 089f32842ac..873669901f6 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -445,11 +445,34 @@ function getReactionEmojis(): string[] { ).map((call) => call[2]); } -function expectAckReactionRuntimeOptions(params?: { - accountId?: string; - ackReaction?: string; - removeAckAfterReply?: boolean; -}) { +function requireRecord(value: unknown, label: string): Record { + expect(typeof value).toBe("object"); + expect(value).not.toBeNull(); + if (typeof value !== "object" || value === null) { + throw new Error(`${label} was not an object`); + } + return value as Record; +} + +function expectRecordFields(record: Record, fields: Record) { + for (const [key, value] of Object.entries(fields)) { + expect(record[key]).toEqual(value); + } +} + +function expectAckReactionRuntimeOptions( + options: unknown, + params?: { + accountId?: string; + ackReaction?: string; + removeAckAfterReply?: boolean; + }, +) { + const optionRecord = requireRecord(options, "reaction runtime options"); + requireRecord(optionRecord.rest, "reaction REST client"); + if (params?.accountId) { + expect(optionRecord.accountId).toBe(params.accountId); + } const messages: Record = {}; if (params?.ackReaction) { messages.ackReaction = params.ackReaction; @@ -457,13 +480,52 @@ function expectAckReactionRuntimeOptions(params?: { if (params?.removeAckAfterReply !== undefined) { messages.removeAckAfterReply = params.removeAckAfterReply; } - return expect.objectContaining({ - rest: expect.anything(), - ...(Object.keys(messages).length > 0 - ? { cfg: expect.objectContaining({ messages: expect.objectContaining(messages) }) } - : {}), - ...(params?.accountId ? { accountId: params.accountId } : {}), - }); + if (Object.keys(messages).length > 0) { + const cfg = requireRecord(optionRecord.cfg, "reaction config"); + expectRecordFields(requireRecord(cfg.messages, "reaction message config"), messages); + } +} + +function requireReactionCall( + mock: typeof sendMocks.reactMessageDiscord | typeof sendMocks.removeReactionDiscord, + index: number, +) { + const call = mock.mock.calls[index] as unknown[] | undefined; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`missing reaction call ${index + 1}`); + } + return call; +} + +function expectReactionCallAt( + mock: typeof sendMocks.reactMessageDiscord | typeof sendMocks.removeReactionDiscord, + index: number, + emoji: string, + params?: { + accountId?: string; + ackReaction?: string; + removeAckAfterReply?: boolean; + channelId?: string; + messageId?: string; + }, +) { + const call = requireReactionCall(mock, index); + expect(call[0]).toBe(params?.channelId ?? "c1"); + expect(call[1]).toBe(params?.messageId ?? "m1"); + expect(call[2]).toBe(emoji); + expectAckReactionRuntimeOptions(call[3], params); +} + +function expectReactionCallsContain(channelId: string, messageId: string, emoji: string) { + const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array< + [string, string, string] + >; + const hasCall = calls.some( + ([actualChannelId, actualMessageId, actualEmoji]) => + actualChannelId === channelId && actualMessageId === messageId && actualEmoji === emoji, + ); + expect(hasCall).toBe(true); } function expectReactAckCallAt( @@ -477,13 +539,7 @@ function expectReactAckCallAt( removeAckAfterReply?: boolean; }, ) { - expect(sendMocks.reactMessageDiscord).toHaveBeenNthCalledWith( - index + 1, - params?.channelId ?? "c1", - params?.messageId ?? "m1", - emoji, - expectAckReactionRuntimeOptions(params), - ); + expectReactionCallAt(sendMocks.reactMessageDiscord, index, emoji, params); } function expectRemoveAckCallAt( @@ -497,13 +553,7 @@ function expectRemoveAckCallAt( removeAckAfterReply?: boolean; }, ) { - expect(sendMocks.removeReactionDiscord).toHaveBeenNthCalledWith( - index + 1, - params?.channelId ?? "c1", - params?.messageId ?? "m1", - emoji, - expectAckReactionRuntimeOptions(params), - ); + expectReactionCallAt(sendMocks.removeReactionDiscord, index, emoji, params); } function createMockDraftStreamForTest() { @@ -512,13 +562,20 @@ function createMockDraftStreamForTest() { return draftStream; } +function expectPreviewEditContent(content: string) { + const call = editMessageDiscord.mock.calls[0] as unknown[] | undefined; + expect(call).toBeDefined(); + if (!call) { + throw new Error("missing preview edit call"); + } + expect(call[0]).toBe("c1"); + expect(call[1]).toBe("preview-1"); + expect(call[2]).toEqual({ content }); + requireRecord(requireRecord(call[3], "preview edit options").rest, "preview edit REST client"); +} + function expectSinglePreviewEdit() { - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: "Hello\nWorld" }, - expect.objectContaining({ rest: expect.anything() }), - ); + expectPreviewEditContent("Hello\nWorld"); expect(deliverDiscordReply).not.toHaveBeenCalled(); } @@ -601,12 +658,13 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); expect(sendMocks.reactMessageDiscord).toHaveBeenCalled(); - expect(sendMocks.reactMessageDiscord.mock.calls[0]?.[3]).toEqual( - expect.objectContaining({ rest: feedbackRest }), - ); - expect(deliverDiscordReply).toHaveBeenCalledWith( - expect.objectContaining({ rest: deliveryRest }), + const feedbackOptions = requireRecord( + sendMocks.reactMessageDiscord.mock.calls[0]?.[3], + "feedback reaction options", ); + expect(feedbackOptions.rest).toBe(feedbackRest); + const deliveryParams = requireRecord(deliverDiscordReply.mock.calls[0]?.[0], "delivery params"); + expect(deliveryParams.rest).toBe(deliveryRest); expect(feedbackRest).not.toBe(deliveryRest); }); @@ -669,12 +727,9 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); await vi.runAllTimersAsync(); - const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array< - [string, string, string] - >; - expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "šŸ“ˆ"])); - expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "āœ‰ļø"])); - expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", DEFAULT_EMOJIS.done])); + expectReactionCallsContain("c1", "m1", "šŸ“ˆ"); + expectReactionCallsContain("c1", "m1", "āœ‰ļø"); + expectReactionCallsContain("c1", "m1", DEFAULT_EMOJIS.done); }); it("resolves tracked reaction to targets like the Discord reaction action", async () => { @@ -702,16 +757,20 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); await vi.runAllTimersAsync(); - expect(discordTargetMocks.resolveDiscordTargetChannelId).toHaveBeenCalledWith( - "user:u1", - expect.objectContaining({ accountId: "default" }), + const resolveCall = discordTargetMocks.resolveDiscordTargetChannelId.mock.calls[0] as + | unknown[] + | undefined; + expect(resolveCall).toBeDefined(); + if (!resolveCall) { + throw new Error("missing Discord target resolve call"); + } + expect(resolveCall[0]).toBe("user:u1"); + expect(requireRecord(resolveCall[1], "Discord target resolve options").accountId).toBe( + "default", ); - const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array< - [string, string, string] - >; - expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", "šŸ“ˆ"])); - expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", "āœ‰ļø"])); - expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", DEFAULT_EMOJIS.done])); + expectReactionCallsContain("dm-u1", "m1", "šŸ“ˆ"); + expectReactionCallsContain("dm-u1", "m1", "āœ‰ļø"); + expectReactionCallsContain("dm-u1", "m1", DEFAULT_EMOJIS.done); }); it("shows stall emojis for long no-progress runs", async () => { @@ -911,7 +970,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { BodyForAgent: "hello from discord voice", CommandBody: "hello from discord voice", Transcript: "hello from discord voice", @@ -939,7 +998,7 @@ describe("processDiscordMessage session routing", () => { to: "user:U1", accountId: "default", }); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { ChatType: "direct", From: "discord:U1", To: "user:U1", @@ -978,16 +1037,22 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastRouteUpdate()).toMatchObject({ + expectRecordFields(requireRecord(getLastRouteUpdate(), "last route update"), { sessionKey: "agent:main:main", channel: "discord", to: "user:222", accountId: "default", - mainDmOwnerPin: { + }); + expectRecordFields( + requireRecord( + requireRecord(getLastRouteUpdate(), "last route update").mainDmOwnerPin, + "main DM owner pin", + ), + { ownerRecipient: "111", senderRecipient: "222", }, - }); + ); }); it("stores group lastRoute with channel target", async () => { @@ -1016,7 +1081,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchReplyOptions()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchReplyOptions(), "dispatch reply options"), { sourceReplyDeliveryMode: "message_tool_only", disableBlockStreaming: true, }); @@ -1098,7 +1163,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { MessageSid: "orig-123", MessageSidFull: "proxy-456", }); @@ -1169,7 +1234,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { SessionKey: "agent:main:subagent:child", MessageThreadId: "thread-1", }); @@ -1202,7 +1267,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { SessionKey: "agent:main:discord:channel:thread-1", MessageThreadId: "thread-1", ModelParentSessionKey: "agent:main:discord:channel:parent-1", @@ -1246,7 +1311,7 @@ describe("processDiscordMessage session routing", () => { await runProcessDiscordMessage(ctx); expect(rest.get).toHaveBeenCalled(); - expect(getLastDispatchCtx()).toMatchObject({ + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { SessionKey: threadSessionKey, MessageThreadId: "thread-1", }); @@ -1320,12 +1385,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith(expect.stringContaining("Exec")); expect(draftStream.update).toHaveBeenCalledWith(expect.stringContaining("exec done")); - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: "done" }, - expect.objectContaining({ rest: expect.anything() }), - ); + expectPreviewEditContent("done"); expect(deliverDiscordReply).not.toHaveBeenCalled(); }); @@ -1365,12 +1425,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: longReply }, - expect.objectContaining({ rest: expect.anything() }), - ); + expectPreviewEditContent(longReply); expect(deliverDiscordReply).not.toHaveBeenCalled(); }); @@ -1535,9 +1590,12 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledTimes(1); expect(draftStream.update).toHaveBeenCalledWith("Shelling"); expect(draftStream.flush).toHaveBeenCalledTimes(1); - expect(dispatchInboundMessage.mock.calls[0]?.[0]?.replyOptions).toMatchObject({ - suppressDefaultToolProgressMessages: true, - }); + expect( + requireRecord( + dispatchInboundMessage.mock.calls[0]?.[0]?.replyOptions, + "dispatch reply options", + ).suppressDefaultToolProgressMessages, + ).toBe(true); }); it("does not start Discord progress drafts for text-only accepted turns", async () => { @@ -1588,12 +1646,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("Shelling\nšŸ› ļø Exec\n• exec done"); expect(deliverDiscordReply).not.toHaveBeenCalled(); - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: "done" }, - expect.objectContaining({ rest: expect.anything() }), - ); + expectPreviewEditContent("done"); }); it("uses raw tool-progress detail in Discord progress drafts", async () => {