From 8015b933b73c801a1682e99d06a9a2ab8edb505e Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Mon, 5 Oct 2026 15:06:46 -0400 Subject: [PATCH] Fix Linq reactions targeting stale messages --- agent/channels/linq.ts | 10 ++- .../channels/linq-message-delivery.test.ts | 71 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index 0ade7d2d..fb639489 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -113,7 +113,15 @@ export default linqChannel({ "react_to_message requires an active Linq conversation thread." ); } - const messageId = context.thread.toJSON().currentMessage?.id; + // The thread is a persisted snapshot; auth identifies this incoming message. + const target = resolveLinqReplyTarget( + { kind: "current" }, + session.session.auth + ); + const messageId = + target?.conversationId === context.thread.id + ? target.messageId + : undefined; if (!messageId) { throw new Error("react_to_message requires a current Linq message."); } diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index c0e8b55e..435a9d06 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -993,6 +993,77 @@ describe("Linq message delivery", () => { ); expect(post).not.toHaveBeenCalled(); }); + + it.each(["add", "remove"] as const)( + "%s targets the incoming message when the thread snapshot is stale", + async (operation) => { + const { addReaction, context, removeReaction } = + handlerContext("first-message"); + + await handleActionResult( + reactToMessageResult({ operation, type: "thumbs_up" }), + context, + sessionContext("linq-message", undefined, "latest-message") + ); + + const deliver = operation === "add" ? addReaction : removeReaction; + const unused = operation === "add" ? removeReaction : addReaction; + expect(deliver).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + "latest-message", + "thumbs_up" + ); + expect(unused).not.toHaveBeenCalled(); + } + ); + + it("reacts to the incoming message without a cached current message", async () => { + const { addReaction, context } = handlerContext(null); + + await handleActionResult( + reactToMessageResult({ operation: "add", type: "thumbs_up" }), + context, + sessionContext() + ); + + expect(addReaction).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + "message-1", + "thumbs_up" + ); + }); + + it("does not react to a cached message without an incoming message ID", async () => { + const { addReaction, context, removeReaction } = handlerContext(); + + await expect( + handleActionResult( + reactToMessageResult({ operation: "add", type: "thumbs_up" }), + context, + sessionContext("linq-message", undefined, null) + ) + ).rejects.toThrow("react_to_message requires a current Linq message."); + + expect(addReaction).not.toHaveBeenCalled(); + expect(removeReaction).not.toHaveBeenCalled(); + }); + + it("rejects an incoming reaction target from a different conversation", async () => { + const { addReaction, context, removeReaction } = handlerContext(); + const session = sessionContext(); + session.session.auth.current.attributes.conversationId = "linq:other-chat"; + + await expect( + handleActionResult( + reactToMessageResult({ operation: "add", type: "thumbs_up" }), + context, + session + ) + ).rejects.toThrow("react_to_message requires a current Linq message."); + + expect(addReaction).not.toHaveBeenCalled(); + expect(removeReaction).not.toHaveBeenCalled(); + }); }); function sendMessageResult(