From d8c8c486064efbb60dd38f5656863d1f35320cb3 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Mon, 5 Oct 2026 15:49:05 -0400 Subject: [PATCH 1/4] Migrate reactions to explicit targets and awaited delivery --- agent/channels/linq.ts | 56 +--- .../instructions/content/role/interactive.md | 2 +- agent/lib/linq/transport.ts | 33 ++ agent/tools/messaging.ts | 58 +++- .../[sessionId]/_lib/message-events.test.ts | 26 ++ .../chat/[sessionId]/_lib/message-events.ts | 7 +- evals/agent/conversation.eval.ts | 66 +++- package.json | 3 +- pnpm-lock.yaml | 9 +- shared/chat/reaction.test.ts | 93 ++++++ shared/chat/reaction.ts | 59 ++-- .../agent/channels/linq-inbound-auth.test.ts | 44 ++- .../channels/linq-message-delivery.test.ts | 124 ++----- tests/agent/tools/reactions.test.ts | 313 ++++++++++++++++++ 14 files changed, 705 insertions(+), 188 deletions(-) create mode 100644 shared/chat/reaction.test.ts create mode 100644 tests/agent/tools/reactions.test.ts diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index fb639489..45316025 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -11,7 +11,6 @@ import { resolveLinqReplyTarget } from "@agent/lib/reply-targets"; import { scopeFromPrincipal } from "@agent/lib/principal-scope"; import { getAuth } from "@db/services/auth"; import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; -import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; import { accessScopeForUser } from "@shared/identity/access-scope"; import { normalizeAuthPhoneNumber } from "@shared/identity/phone-number"; import { prepareLinqImageArtifactDelivery } from "../lib/linq-image-artifact/delivery"; @@ -37,6 +36,14 @@ const verifiedPhoneUserSchema = z.object({ const unavailableReplyTargetSchema = z.object({ status: z.union([z.literal(400), z.literal(404)]), }); +const incomingReplySchema = z.object({ + reply_to: z + .object({ + message_id: z.uuid(), + part_index: z.number().int().nonnegative().optional(), + }) + .nullish(), +}); type LinqMessageContent = Parameters< LinqAPIV3["chats"]["messages"]["send"] @@ -106,43 +113,6 @@ export default linqChannel({ }); }, async "action.result"(event, context, session) { - const reaction = reactToMessageToolResultSchema.safeParse(event.result); - if (event.status === "completed" && reaction.success) { - if (!context.thread) { - throw new Error( - "react_to_message requires an active Linq conversation thread." - ); - } - // 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."); - } - const adapter = context.bot.getAdapter("linq"); - if (reaction.data.output.operation === "remove") { - await adapter.removeReaction( - context.thread.id, - messageId, - reaction.data.output.type - ); - } else { - await adapter.addReaction( - context.thread.id, - messageId, - reaction.data.output.type - ); - } - await finalizeScheduledReportDelivery(session); - return; - } - const message = sendMessageToolResultSchema.safeParse(event.result); if ( event.status === "completed" && @@ -354,7 +324,17 @@ export default linqChannel({ } const principalId = `better-auth:${verifiedUserId}`; const scope = accessScopeForUser(principalId); + const reply = incomingReplySchema.safeParse(message.raw); + const references = [ + `[Message: ${JSON.stringify({ messageId: message.id, sender: "user" })}]`, + ]; + if (reply.success && reply.data.reply_to) { + references.push( + `[Reply to: ${JSON.stringify({ messageId: reply.data.reply_to.message_id, partIndex: reply.data.reply_to.part_index })}]` + ); + } return { + context: references, auth: { ...auth, attributes: { diff --git a/agent/instructions/content/role/interactive.md b/agent/instructions/content/role/interactive.md index 41486d95..6e050192 100644 --- a/agent/instructions/content/role/interactive.md +++ b/agent/instructions/content/role/interactive.md @@ -52,7 +52,7 @@ The main conversation is the control plane. Coordinate the user's work there and - On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq renders reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible. - Use `schedules-create` to create a one-time reminder, recurring job, monitor, or scheduled follow-up. Use `kind: "calendar"` with the user's IANA timezone for wall-clock recurrence, `kind: "interval"` for elapsed intervals, and `kind: "once"` for one future instant. Summarize the exact task in `prompt`. Use `schedules-list` before changing an ambiguous schedule and `schedules-update` to edit, pause, resume, or delete it. - When the user answers a question previously sent for a scheduled task, call `schedules-answer` with the internal run ID retained in conversation context and their answer. The parked background run continues from the exact point where it asked. -- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` can add or remove any supported Tapback on the current user message: `thumbs_up`, `thumbs_down`, `heart`, `laugh`, `exclamation` (emphasis), or `question`. +- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` adds or removes one Unicode emoji on the supplied `messageId`. Use the quoted message's ID when reacting to an older reply target; never invent IDs. - Eve and Linq own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation. - After a `send_message`, `share_contact`, or `react_to_message` call, never repeat or summarize it in assistant text. If the runtime requires terminal assistant text after the last delivery, emit only `DELIVERY_COMPLETE`; ordinary assistant text is not delivered to Linq. - The worker's structured result is coordinator-facing only. Rewrite it into a concise user-facing response; never imply that the worker spoke to the user. diff --git a/agent/lib/linq/transport.ts b/agent/lib/linq/transport.ts index 2081a1d8..fed7fc8b 100644 --- a/agent/lib/linq/transport.ts +++ b/agent/lib/linq/transport.ts @@ -1,6 +1,9 @@ import { connectLinqCredentials } from "@vercel/connect/eve"; +import { createLinqAdapter } from "@linqapp/chat-sdk-adapter"; import { LinqAPIV3 } from "@linqapp/sdk"; +import { z } from "zod"; import { env } from "@shared/environment"; +import type { reactToMessageInputSchema } from "@shared/chat/reaction"; export const linqCredentials = env.LINQ_CONNECTOR ? connectLinqCredentials(env.LINQ_CONNECTOR) @@ -21,3 +24,33 @@ export async function sendNativeLinqMessage( const client = new LinqAPIV3({ apiKey }); return client.chats.messages.send(chatId, { message }, options); } + +export async function sendNativeLinqReaction( + threadId: string, + reaction: z.infer, + signal: AbortSignal +) { + const messageId = z + .uuid({ + error: + "The target messageId must be a valid Linq UUID without punctuation.", + }) + .parse(reaction.messageId); + const apiKey = await linqCredentials.apiKey(); + const adapter = createLinqAdapter({ credentials: () => ({ apiKey }) }); + const { chatId, pendingHandle } = adapter.decodeThreadId(threadId); + if (!chatId || pendingHandle) { + throw new Error("Reactions require an existing Linq conversation."); + } + const client = new LinqAPIV3({ apiKey, maxRetries: 0 }); + const target = await client.messages.retrieve(messageId, { signal }); + if (target.chat_id !== chatId) { + throw new Error("Message target is not in the current conversation."); + } + signal.throwIfAborted(); + if (reaction.operation === "remove") { + await adapter.removeReaction(threadId, messageId, reaction.emoji); + } else { + await adapter.addReaction(threadId, messageId, reaction.emoji); + } +} diff --git a/agent/tools/messaging.ts b/agent/tools/messaging.ts index 7dede9d6..05af2e10 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -2,16 +2,16 @@ import { defineDynamic, defineTool, toolOutput } from "eve/tools"; import { defineState } from "eve/context"; import { z } from "zod"; import { resolveModeValue } from "../lib/mode"; -import { sendNativeLinqMessage } from "@agent/lib/linq/transport"; +import { + sendNativeLinqMessage, + sendNativeLinqReaction, +} from "@agent/lib/linq/transport"; import { scopeFromPrincipal } from "@agent/lib/principal-scope"; import { readLinqOnboardingPhoneNumber } from "@db/services/auth/linq"; import { getInstallationSecrets } from "@db/services/installation-secrets"; import { openInstinctContactUrl } from "@shared/chat/contact-card"; import { env } from "@shared/environment"; -import { - addReactionToMessageOutputSchema, - reactToMessageOutputSchema, -} from "@shared/chat/reaction"; +import { reactToMessageInputSchema } from "@shared/chat/reaction"; import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; const contactDelivery = defineState<{ @@ -141,23 +141,53 @@ function defineShareContact() { export default defineDynamic({ events: { - "turn.started": (_event, context) => { + "turn.started": (event, context) => { const isLinq = context.channel.kind === "channel:linq"; const send_message = defineSendMessage(); + const turn = z + .object({ data: z.object({ turnId: z.string() }) }) + .safeParse(event); + const browserTarget = + !isLinq && turn.success + ? ` The current browser messageId is ${turn.data.data.turnId}:user.` + : ""; const react_to_message = defineTool({ - description: isLinq - ? "Add or remove a native iMessage Tapback on the user's current message. Use this instead of send_message when a reaction fully communicates a lightweight acknowledgement and words would add nothing. Supports thumbs_up, thumbs_down, heart, laugh, exclamation (emphasis), and question." - : "Acknowledge the user's current message with one compact reaction displayed in the conversation. Use this instead of send_message when the reaction fully communicates the response and words would add nothing. Supports thumbs_up, thumbs_down, heart, laugh, exclamation (emphasis), and question.", - inputSchema: isLinq - ? reactToMessageOutputSchema - : addReactionToMessageOutputSchema, - execute(reaction) { + availableInSubagents: false, + description: + "Add or remove a native emoji reaction to a specific message in the current conversation. Set messageId to the exact supplied ID of that message. When the user replies to an older message and asks to react to it, use the supplied Reply to messageId. Never invent an ID or default to the session's first message. Supply exactly one real Unicode emoji, not a name or shortcode. Use a reaction for a lightweight acknowledgement; deliver answers that need words through send_message." + + browserTarget, + inputSchema: reactToMessageInputSchema, + async execute(input, toolContext) { + // Runtime validation must survive serialization of the model-facing schema. + const reaction = reactToMessageInputSchema.parse(input); + if (isLinq) { + const caller = toolContext.session.auth.current; + const thread = z + .string() + .startsWith("linq:") + .safeParse(caller?.attributes.conversationId); + if ( + caller?.principalType !== "user" || + caller.attributes.conversationChannel !== "linq" || + !thread.success || + caller.attributes.linqThreadId !== thread.data + ) { + throw new Error( + "Reactions require the current authenticated Linq conversation. Nothing was sent." + ); + } + await sendNativeLinqReaction( + thread.data, + reaction, + toolContext.abortSignal + ); + } return reaction; }, toModelOutput() { return toolOutput.text( - "The reaction was submitted to the active conversation. Do not repeat it in assistant text." + "The reaction was accepted by the active channel. Do not repeat it or duplicate it in assistant text." ); }, }); diff --git a/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts b/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts index 50e0cfa5..e26c5c01 100644 --- a/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts +++ b/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts @@ -113,6 +113,32 @@ describe("iMessage event projection", () => { expect(sentMessages(events).has("turn-1:assistant")).toBe(false); }); + it("projects a new Unicode reaction without changing the emoji", () => { + const events = [ + toolResult("react_to_message", { + operation: "add", + messageId: "older-message", + emoji: "πŸ‘©πŸ½β€πŸ’»", + }), + ]; + expect(sentMessages(events).get("turn-1:assistant")).toEqual([ + expect.objectContaining({ + parts: [expect.objectContaining({ text: "πŸ‘©πŸ½β€πŸ’»" })], + }), + ]); + }); + + it("does not project a removed Unicode reaction as a new message", () => { + const events = [ + toolResult("react_to_message", { + operation: "remove", + messageId: "older-message", + emoji: "πŸ‘€", + }), + ]; + expect(sentMessages(events).size).toBe(0); + }); + it("projects a native link-preview send as its URL", () => { const events = [ toolResult( diff --git a/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts b/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts index 7497e8ea..076fe003 100644 --- a/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts +++ b/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts @@ -1,9 +1,6 @@ import type { MessageStreamEvent } from "eve/client"; import type { EveMessagePart } from "eve/react"; -import { - reactionTextFor, - reactToMessageToolResultSchema, -} from "@shared/chat/reaction"; +import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; export function messageTimestamps(events: readonly MessageStreamEvent[]) { @@ -56,7 +53,7 @@ export function sentMessages(events: readonly MessageStreamEvent[]) { parts.push({ state: "done", stepIndex: event.data.stepIndex, - text: reactionTextFor(reaction.output.type), + text: reaction.output.emoji, type: "text", }); } else if (delivery) { diff --git a/evals/agent/conversation.eval.ts b/evals/agent/conversation.eval.ts index fefc0c98..f39b7587 100644 --- a/evals/agent/conversation.eval.ts +++ b/evals/agent/conversation.eval.ts @@ -1,7 +1,7 @@ import { defineEval, type EveEvalContext } from "eve/evals"; import { includes, satisfies } from "eve/evals/expect"; import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; -import { reactToMessageOutputSchema } from "@shared/chat/reaction"; +import { reactToMessageInputSchema } from "@shared/chat/reaction"; import { agentEvalTags, assertPlainTextDelivery, @@ -107,14 +107,22 @@ const reactionEvals = [ const thanked = await answered.session.send("perfect, thanks!"); thanked.expectOk(); thanked.succeeded(); + const incoming = thanked.events.findLast( + (event) => event.type === "message.received" + ); + if (!incoming) + throw new Error( + "The acknowledgement requires an incoming message event." + ); + const messageId = `${incoming.data.turnId}:user`; thanked.calledTool("react_to_message", { count: 1, input: (input) => { - const parsed = reactToMessageOutputSchema.safeParse(input); + const parsed = reactToMessageInputSchema.safeParse(input); return ( parsed.success && parsed.data.operation === "add" && - ["heart", "thumbs_up"].includes(parsed.data.type) + parsed.data.messageId === messageId ); }, status: "completed", @@ -123,6 +131,58 @@ const reactionEvals = [ thanked.maxToolCalls(1); }, }), + defineEval({ + description: + "Reacts to an older quoted message instead of the incoming reply", + tags: [...agentEvalTags, "conversation", "reaction", "reaction-target"], + async test(t) { + const older = "00000000-0000-4000-8000-000000000001"; + const turn = await t.send("like this old message", { + clientContext: [ + '[Message: {"messageId":"00000000-0000-4000-8000-000000000002","sender":"user"}]', + `[Reply to: ${JSON.stringify({ messageId: older })}]`, + ], + }); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("react_to_message", { + count: 1, + input: (input) => { + const parsed = reactToMessageInputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.messageId === older && + parsed.data.emoji === "πŸ‘" && + parsed.data.operation === "add" + ); + }, + status: "completed", + }); + turn.notCalledTool("send_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + description: "Uses the requested Unicode emoji on the supplied message ID", + tags: [...agentEvalTags, "conversation", "reaction", "reaction-target"], + async test(t) { + const messageId = "00000000-0000-4000-8000-000000000002"; + const turn = await t.send("React to this message with πŸ‘€", { + clientContext: [ + `[Message: ${JSON.stringify({ messageId, sender: "user" })}]`, + ], + }); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("react_to_message", { + count: 1, + input: { messageId, emoji: "πŸ‘€", operation: "add" }, + status: "completed", + }); + turn.notCalledTool("send_message"); + turn.maxToolCalls(1); + }, + }), defineEval({ description: "Uses text when an acknowledgement also asks a question", tags: [...agentEvalTags, "conversation", "reaction", "smoke"], diff --git a/package.json b/package.json index d2f7b217..19bbbacb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@googleapis/calendar": "^16.0.0", "@googleapis/gmail": "^18.0.0", "@googleapis/people": "^8.0.0", + "@linqapp/chat-sdk-adapter": "0.5.1", "@linqapp/sdk": "0.47.1", "@onkernel/browser-loop": "0.12.0", "@onkernel/sdk": "^0.96.0", @@ -34,6 +35,7 @@ "cmdk": "1.1.1", "credit-card-type": "^10.3.0", "drizzle-orm": "^0.45.2", + "emoji-regex": "10.6.0", "eve": "0.66.3", "evlog": "2.28.1", "lucide-react": "1.34.0", @@ -51,7 +53,6 @@ }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", - "@linqapp/chat-sdk-adapter": "0.5.1", "@next/env": "16.3.3", "@oxlint/plugins": "1.80.0", "@types/node": "24.13.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75eadfbd..153de209 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@googleapis/people': specifier: ^8.0.0 version: 8.0.0 + '@linqapp/chat-sdk-adapter': + specifier: 0.5.1 + version: 0.5.1(patch_hash=78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7)(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) '@linqapp/sdk': specifier: 0.47.1 version: 0.47.1 @@ -108,6 +111,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0) + emoji-regex: + specifier: 10.6.0 + version: 10.6.0 eve: specifier: 0.66.3 version: 0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) @@ -154,9 +160,6 @@ importers: '@electric-sql/pglite': specifier: ^0.5.8 version: 0.5.8 - '@linqapp/chat-sdk-adapter': - specifier: 0.5.1 - version: 0.5.1(patch_hash=78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7)(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) '@next/env': specifier: 16.3.3 version: 16.3.3 diff --git a/shared/chat/reaction.test.ts b/shared/chat/reaction.test.ts new file mode 100644 index 00000000..657e798b --- /dev/null +++ b/shared/chat/reaction.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + reactToMessageInputSchema, + reactToMessageOutputSchema, +} from "./reaction"; + +describe("reaction contracts", () => { + it.each([ + "πŸ‘€", + "πŸ€”", + "πŸ™Œ", + "πŸ‘πŸ½", + "πŸ‘©πŸ½β€πŸ’»", + "πŸ‡ΊπŸ‡Έ", + "1️⃣", + "πŸ³οΈβ€πŸŒˆ", + "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", + "❀️", + "❀", + "‼️", + "βœ…", + ])("accepts one Unicode emoji: %s", (emoji) => { + expect( + reactToMessageInputSchema.parse({ emoji, messageId: "message-2" }) + ).toEqual({ emoji, messageId: "message-2", operation: "add" }); + }); + it.each([ + "", + "thumbs_up", + ":eyes:", + "working πŸ‘€", + "πŸ‘€πŸ‘", + "πŸ‘€\n", + " πŸ‘€", + "1", + "πŸ‡Ί", + "🏻", + "\uFE0F", + "πŸ¦„β€πŸ¦„", + "πŸ‘€πŸ½", + "πŸ‘€\u200D", + "\uD83D", + ])("rejects text, multiple emojis, and invalid sequences: %j", (emoji) => { + expect(() => + reactToMessageInputSchema.parse({ emoji, messageId: "message-2" }) + ).toThrow("Provide exactly one real Unicode emoji"); + }); + it("requires an explicit target for new reactions", () => { + expect(reactToMessageInputSchema.safeParse({ emoji: "πŸ‘" }).success).toBe( + false + ); + expect( + reactToMessageInputSchema.safeParse({ emoji: "πŸ‘", messageId: "" }) + .success + ).toBe(false); + }); + it("does not put the Unicode table in the model schema", () => { + const schema = z.toJSONSchema(reactToMessageInputSchema); + expect(schema.properties?.emoji).toEqual({ + type: "string", + description: + "One Unicode emoji to add or remove from the target message.", + }); + expect(schema.properties).not.toHaveProperty("type"); + }); + it.each([ + ["heart", "❀️"], + ["thumbs_up", "πŸ‘"], + ["thumbs_down", "πŸ‘Ž"], + ["laugh", "πŸ˜‚"], + ["exclamation", "‼️"], + ["question", "❓"], + ])("reads historical named %s reactions", (type, emoji) => { + expect(reactToMessageOutputSchema.parse({ type })).toEqual({ + operation: "add", + emoji, + }); + expect( + reactToMessageOutputSchema.parse({ type, operation: "remove" }) + ).toEqual({ operation: "remove", emoji }); + expect(reactToMessageInputSchema.safeParse({ type }).success).toBe(false); + }); + it("does not hide an invalid new emoji behind a legacy reaction type", () => { + expect( + reactToMessageOutputSchema.safeParse({ + messageId: "message-2", + emoji: "not-an-emoji", + type: "heart", + }).success + ).toBe(false); + }); +}); diff --git a/shared/chat/reaction.ts b/shared/chat/reaction.ts index 74fc4aaa..45f5ce3e 100644 --- a/shared/chat/reaction.ts +++ b/shared/chat/reaction.ts @@ -1,36 +1,55 @@ +import emojiRegex from "emoji-regex"; import { z } from "zod"; -const reactionTypeSchema = z.enum([ - "thumbs_up", - "thumbs_down", - "heart", - "laugh", - "exclamation", - "question", -]); +const emojiPattern = new RegExp(emojiRegex().source); -export const reactToMessageOutputSchema = z.object({ +export const reactToMessageInputSchema = z.object({ + messageId: z + .string() + .min(1) + .describe( + "Exact supplied ID of the message to react to in this conversation." + ), operation: z.enum(["add", "remove"]).default("add"), - type: reactionTypeSchema, + emoji: z + .string() + .refine( + (emoji) => + emojiPattern.exec(emoji)?.[0] === emoji && + !/^\p{Emoji_Component}$/u.test(emoji), + "Provide exactly one real Unicode emoji, not a name, text, or multiple emojis." + ) + .describe("One Unicode emoji to add or remove from the target message."), }); -export const addReactionToMessageOutputSchema = - reactToMessageOutputSchema.extend({ - operation: z.literal("add").default("add"), - }); - -const reactionText = { +// Persisted tool results used named reactions and did not record their target. +const legacyReactionText = { exclamation: "‼️", heart: "❀️", laugh: "πŸ˜‚", question: "❓", thumbs_down: "πŸ‘Ž", thumbs_up: "πŸ‘", -} as const satisfies Record, string>; +} as const; -export function reactionTextFor(type: z.infer) { - return reactionText[type]; -} +const legacyReactionTypeSchema = z.enum( + Object.keys(legacyReactionText).filter( + (key): key is keyof typeof legacyReactionText => + Object.hasOwn(legacyReactionText, key) + ) +); + +export const reactToMessageOutputSchema = z.union([ + reactToMessageInputSchema, + reactToMessageInputSchema + .omit({ emoji: true, messageId: true }) + .extend({ type: legacyReactionTypeSchema }) + .strict() + .transform(({ type, operation }) => ({ + operation, + emoji: legacyReactionText[type], + })), +]); export const reactToMessageToolResultSchema = z.object({ kind: z.literal("tool-result"), diff --git a/tests/agent/channels/linq-inbound-auth.test.ts b/tests/agent/channels/linq-inbound-auth.test.ts index 383518d9..044db535 100644 --- a/tests/agent/channels/linq-inbound-auth.test.ts +++ b/tests/agent/channels/linq-inbound-auth.test.ts @@ -114,6 +114,46 @@ describe("Linq inbound authentication", () => { expect(result?.auth?.attributes.workspaceId).toMatch( /^personal:[0-9a-f]{32}$/ ); + expect(result?.context).toEqual([ + '[Message: {"messageId":"message-1","sender":"user"}]', + ]); + }); + + it("supplies the older quoted message ID alongside the incoming reply", async () => { + capture.findOne.mockResolvedValue({ + id: "user-1", + phoneNumberVerified: true, + }); + const result = await onMessage( + threadContext(), + linqMessage("+15550100011", { + reply_to: { + message_id: "00000000-0000-4000-8000-000000000002", + part_index: 2, + }, + }) + ); + expect(result?.context).toEqual([ + '[Message: {"messageId":"message-1","sender":"user"}]', + '[Reply to: {"messageId":"00000000-0000-4000-8000-000000000002","partIndex":2}]', + ]); + expect(result?.auth?.attributes.linqMessageId).toBe("message-1"); + }); + + it("does not present a malformed reply target as a usable message ID", async () => { + capture.findOne.mockResolvedValue({ + id: "user-1", + phoneNumberVerified: true, + }); + const result = await onMessage( + threadContext(), + linqMessage("+15550100011", { + reply_to: { message_id: "invented-message", part_index: -1 }, + }) + ); + expect(result?.context).toEqual([ + '[Message: {"messageId":"message-1","sender":"user"}]', + ]); }); }); @@ -132,7 +172,7 @@ function threadContext(): InboundContext { return identity as InboundContext; } -function linqMessage(handle: string) { +function linqMessage(handle: string, raw: Message["raw"] = {}) { return new Message({ attachments: [], author: { @@ -148,7 +188,7 @@ function linqMessage(handle: string) { dateSent: new Date("2026-09-03T00:00:00.000Z"), edited: false, }, - raw: {}, + raw, text: "list my vault items", threadId: "linq:dm:chat-1", }); diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index 435a9d06..3b477995 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -953,117 +953,39 @@ describe("Linq message delivery", () => { }); }); - it.each([ - "thumbs_up", - "thumbs_down", - "heart", - "laugh", - "exclamation", - "question", - ] as const)("adds the native %s Tapback", async (type) => { - const { addReaction, context, post } = handlerContext(); - - await handleActionResult( - reactToMessageResult({ operation: "add", type }), - context, - sessionContext() - ); - - expect(addReaction).toHaveBeenCalledExactlyOnceWith( - "linq:dm:chat-1", - "message-1", - type - ); - expect(post).not.toHaveBeenCalled(); - }); - - it("removes a native Tapback", async () => { - const { context, post, removeReaction } = handlerContext(); - - await handleActionResult( - reactToMessageResult({ operation: "remove", type: "heart" }), - context, - sessionContext() - ); - - expect(removeReaction).toHaveBeenCalledExactlyOnceWith( - "linq:dm:chat-1", - "message-1", - "heart" - ); - expect(post).not.toHaveBeenCalled(); - }); - it.each(["add", "remove"] as const)( - "%s targets the incoming message when the thread snapshot is stale", + "does not resend a legacy %s reaction from the channel hook", async (operation) => { - const { addReaction, context, removeReaction } = - handlerContext("first-message"); - + const { addReaction, context, post, removeReaction } = handlerContext(); await handleActionResult( reactToMessageResult({ operation, type: "thumbs_up" }), context, - sessionContext("linq-message", undefined, "latest-message") + sessionContext() ); - - 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(); + expect(addReaction).not.toHaveBeenCalled(); + expect(removeReaction).not.toHaveBeenCalled(); + expect(post).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" }), + it.each(["add", "remove"] as const)( + "does not resend a Unicode %s reaction from the channel hook", + async (operation) => { + const { addReaction, context, post, removeReaction } = handlerContext(); + await handleActionResult( + reactToMessageResult({ + operation, + messageId: "older-message", + emoji: "πŸ‘€", + }), context, - session - ) - ).rejects.toThrow("react_to_message requires a current Linq message."); - - expect(addReaction).not.toHaveBeenCalled(); - expect(removeReaction).not.toHaveBeenCalled(); - }); + sessionContext() + ); + expect(addReaction).not.toHaveBeenCalled(); + expect(removeReaction).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + } + ); }); function sendMessageResult( diff --git a/tests/agent/tools/reactions.test.ts b/tests/agent/tools/reactions.test.ts new file mode 100644 index 00000000..038a3a9b --- /dev/null +++ b/tests/agent/tools/reactions.test.ts @@ -0,0 +1,313 @@ +import type { DynamicResolveContext, ToolContext } from "eve/tools"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import messaging from "@agent/tools/messaging"; +import { reactToMessageInputSchema } from "@shared/chat/reaction"; +import type * as EnvModule from "@shared/environment"; + +const controls = vi.hoisted(() => ({ + fetch: vi.fn(), + apiKey: vi.fn<() => Promise>(), + chatId: "chat-1", +})); +vi.mock("@vercel/connect/eve", () => ({ + connectLinqCredentials: () => ({ apiKey: controls.apiKey }), +})); +vi.mock("@shared/environment", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, env: { ...original.env, LINQ_CONNECTOR: "linq/test" } }; +}); + +const first = "00000000-0000-4000-8000-000000000001"; +const second = "00000000-0000-4000-8000-000000000002"; +const third = "00000000-0000-4000-8000-000000000003"; + +beforeEach(() => { + vi.clearAllMocks(); + controls.chatId = "chat-1"; + controls.apiKey.mockResolvedValue("linq-test-key"); + controls.fetch.mockReset().mockImplementation(async (input, init) => { + const request = new Request(input, init); + return Response.json( + request.url.endsWith("/reactions") + ? { status: "accepted" } + : { id: request.url.split("/").at(-1), chat_id: controls.chatId } + ); + }); + vi.stubGlobal("fetch", controls.fetch); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("react_to_message delivery", () => { + it.each([ + ["πŸ‘", { type: "like" }], + ["πŸ‘Ž", { type: "dislike" }], + ["❀️", { type: "love" }], + ["πŸ˜‚", { type: "laugh" }], + ["‼️", { type: "emphasize" }], + ["❓", { type: "question" }], + ["βœ…", { type: "custom", custom_emoji: "βœ…" }], + ["πŸ‘€", { type: "custom", custom_emoji: "πŸ‘€" }], + ["πŸ‘πŸ½", { type: "custom", custom_emoji: "πŸ‘πŸ½" }], + ["πŸ‘©πŸ½β€πŸ’»", { type: "custom", custom_emoji: "πŸ‘©πŸ½β€πŸ’»" }], + ["πŸ‡ΊπŸ‡Έ", { type: "custom", custom_emoji: "πŸ‡ΊπŸ‡Έ" }], + ] as const)( + "delivers %s add and remove to the explicit message through Linq", + async (emoji, expected) => { + const tool = await reactionTool(); + for (const operation of ["add", "remove"] as const) { + controls.fetch.mockClear(); + const input = { messageId: second, emoji, operation }; + // oxlint-disable-next-line eslint/no-await-in-loop -- Each operation is verified against its own ordered request capture. + await expect(tool.execute(input, toolContext())).resolves.toEqual( + input + ); + expect(controls.fetch).toHaveBeenCalledTimes(2); + const lookup = requestAt(0); + const delivery = requestAt(1); + expect(lookup.url).toBe( + `https://api.linqapp.com/api/partner/v3/messages/${second}` + ); + expect(lookup.method).toBe("GET"); + expect(delivery.url).toBe( + `https://api.linqapp.com/api/partner/v3/messages/${second}/reactions` + ); + expect(delivery.method).toBe("POST"); + // oxlint-disable-next-line eslint/no-await-in-loop -- Read this operation's request before resetting the capture for the next operation. + expect(await delivery.json()).toEqual({ operation, ...expected }); + } + } + ); + + it("targets earlier and newer messages without using the cached incoming message ID", async () => { + const tool = await reactionTool(); + const context = toolContext(); + for (const messageId of [second, third, first, second]) { + // oxlint-disable-next-line eslint/no-await-in-loop -- These calls represent successive reactions within one session. + await tool.execute({ messageId, emoji: "πŸ‘€", operation: "add" }, context); + } + expect( + Array.from({ length: 8 }, (_, index) => requestAt(index).url) + ).toEqual( + [second, third, first, second].flatMap((id) => [ + `https://api.linqapp.com/api/partner/v3/messages/${id}`, + `https://api.linqapp.com/api/partner/v3/messages/${id}/reactions`, + ]) + ); + }); + + it.each(["add", "remove"] as const)( + "rejects %s on another chat's message without a fallback", + async (operation) => { + controls.chatId = "other-chat"; + const tool = await reactionTool(); + await expect( + tool.execute( + { messageId: second, emoji: "πŸ‘€", operation }, + toolContext() + ) + ).rejects.toThrow("current conversation"); + expect(controls.fetch).toHaveBeenCalledOnce(); + } + ); + + it.each(["not-a-message-id", `${second},`])( + "rejects invalid Linq target %s before provider calls", + async (messageId) => { + const tool = await reactionTool(); + await expect( + tool.execute( + { messageId, emoji: "πŸ‘€", operation: "add" }, + toolContext() + ) + ).rejects.toThrow("valid Linq UUID"); + expect(controls.apiKey).not.toHaveBeenCalled(); + expect(controls.fetch).not.toHaveBeenCalled(); + } + ); + + it.each(["thumbs_up", "πŸ‘€πŸ‘", "πŸ‘€πŸ½", "πŸ‘€\n"])( + "rejects invalid emoji %j before delivery and permits correction", + async (emoji) => { + const tool = await reactionTool(); + await expect( + tool.execute( + { messageId: second, emoji, operation: "add" }, + toolContext() + ) + ).rejects.toThrow("Provide exactly one real Unicode emoji"); + expect(controls.fetch).not.toHaveBeenCalled(); + const corrected = { + messageId: second, + emoji: "πŸ‘€", + operation: "add", + } as const; + await expect(tool.execute(corrected, toolContext())).resolves.toEqual( + corrected + ); + } + ); + + it.each([404, 500])( + "fails the tool when target lookup returns %s", + async (status) => { + controls.fetch.mockResolvedValueOnce( + new Response("Target unavailable", { status }) + ); + const tool = await reactionTool(); + await expect( + tool.execute( + { messageId: second, emoji: "πŸ‘", operation: "add" }, + toolContext() + ) + ).rejects.toThrow(String(status)); + expect(controls.fetch).toHaveBeenCalledOnce(); + } + ); + + it("fails the tool when Linq rejects the reaction", async () => { + controls.fetch + .mockResolvedValueOnce(Response.json({ id: second, chat_id: "chat-1" })) + .mockResolvedValueOnce( + new Response("Reaction rejected", { status: 400 }) + ); + const tool = await reactionTool(); + await expect( + tool.execute( + { messageId: second, emoji: "πŸ‘", operation: "add" }, + toolContext() + ) + ).rejects.toThrow("400"); + expect(controls.fetch).toHaveBeenCalledTimes(2); + }); + + it("does not complete until the provider accepts the reaction", async () => { + const response = Promise.withResolvers(); + controls.fetch.mockImplementation(async (input, init) => { + if (!new Request(input, init).url.endsWith("/reactions")) + return Response.json({ id: second, chat_id: "chat-1" }); + return await response.promise; + }); + const tool = await reactionTool(); + const completed = vi.fn<() => void>(); + const call = Promise.resolve( + tool.execute( + { messageId: second, emoji: "πŸ‘", operation: "add" }, + toolContext() + ) + ).then(completed); + await vi.waitFor(() => { + expect(controls.fetch).toHaveBeenCalledTimes(2); + }); + expect(completed).not.toHaveBeenCalled(); + response.resolve(Response.json({ status: "accepted" })); + await call; + expect(completed).toHaveBeenCalledOnce(); + }); + + it("requires authentication for native reactions", async () => { + const tool = await reactionTool(); + const context = toolContext(); + await expect( + tool.execute( + { messageId: second, emoji: "πŸ‘", operation: "add" }, + { + ...context, + session: { + ...context.session, + auth: { current: null, initiator: null }, + }, + } + ) + ).rejects.toThrow("authenticated Linq conversation"); + expect(controls.fetch).not.toHaveBeenCalled(); + }); + + it("rejects a mismatch between the authenticated conversation and Linq thread", async () => { + const tool = await reactionTool(); + const context = toolContext(); + context.session.auth.current.attributes.linqThreadId = "linq:other-chat"; + await expect( + tool.execute( + { messageId: second, emoji: "πŸ‘", operation: "add" }, + context + ) + ).rejects.toThrow("authenticated Linq conversation"); + expect(controls.fetch).not.toHaveBeenCalled(); + }); + + it("preserves explicit Unicode reactions in browser chat without provider delivery", async () => { + const tool = await reactionTool("eve"); + const input = reactToMessageInputSchema.parse({ + messageId: "turn-1:user", + emoji: "πŸ‘©πŸ½β€πŸ’»", + }); + expect(tool.description).toContain( + "current browser messageId is turn-1:user" + ); + await expect(tool.execute(input, toolContext("eve"))).resolves.toEqual( + input + ); + expect(controls.apiKey).not.toHaveBeenCalled(); + expect(controls.fetch).not.toHaveBeenCalled(); + }); +}); + +async function reactionTool(channel: "linq" | "eve" = "linq") { + const context = toolContext(channel); + const dynamic = { + session: context.session, + model: null, + messages: [], + channel: { kind: `channel:${channel}`, metadata: {} }, + } satisfies DynamicResolveContext; + const resolve = messaging.events["turn.started"]; + if (!resolve) throw new Error("Messaging resolver is unavailable"); + const tools = await resolve( + { type: "turn.started", data: { turnId: "turn-1" } }, + dynamic + ); + if (!tools || !("react_to_message" in tools)) + throw new Error("Reaction tool is unavailable"); + return tools.react_to_message; +} + +function toolContext(channel: "linq" | "eve" = "linq") { + return { + abortSignal: new AbortController().signal, + callId: "react-call", + toolName: "react_to_message", + getSandbox: async () => { + throw new Error("Unexpected sandbox access"); + }, + getToken: async () => { + throw new Error("Unexpected token access"); + }, + requireAuth: () => { + throw new Error("Unexpected connection authorization"); + }, + session: { + id: "session-1", + turn: { id: "turn-1", sequence: 0 }, + auth: { + current: { + principalId: "user-1", + principalType: "user", + authenticator: "linq-message", + attributes: { + conversationChannel: channel, + conversationId: "linq:chat-1", + linqThreadId: "linq:chat-1", + linqMessageId: first, + }, + }, + initiator: null, + }, + }, + } satisfies ToolContext; +} + +function requestAt(index: number) { + const [input, init] = controls.fetch.mock.calls[index] ?? []; + if (!input) throw new Error("Expected a Linq request"); + return new Request(input, init); +} From f631ed58336ad62e479059cdd146f9d8bc119dfe Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Mon, 5 Oct 2026 16:30:28 -0400 Subject: [PATCH 2/4] Persist indexed Linq message metadata in history --- agent/channels/linq.ts | 126 ++++++------ .../instructions/content/role/interactive.md | 2 +- agent/lib/linq/content.test.ts | 90 +++++++++ agent/lib/linq/content.ts | 50 +++++ evals/agent/conversation.eval.ts | 63 ++++-- package.json | 3 +- pnpm-lock.yaml | 22 ++- .../agent/channels/linq-inbound-auth.test.ts | 186 +++++++++--------- .../channels/linq-message-delivery.test.ts | 19 +- 9 files changed, 385 insertions(+), 176 deletions(-) create mode 100644 agent/lib/linq/content.test.ts create mode 100644 agent/lib/linq/content.ts diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index 45316025..456adf37 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -1,10 +1,13 @@ import type { LinqAPIV3 } from "@linqapp/sdk"; import type { AdapterPostableMessage } from "chat"; +import type { Message, Thread } from "chat"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createLinqAdapter } from "@linqapp/chat-sdk-adapter"; import { defaultLinqAuth, - linqChannel, type LinqChannelCredentials, } from "eve/channels/linq"; +import { chatSdkChannel } from "eve/channels/chat-sdk"; import { vercelOidc } from "eve/channels/auth"; import { z } from "zod"; import { resolveLinqReplyTarget } from "@agent/lib/reply-targets"; @@ -13,6 +16,7 @@ import { getAuth } from "@db/services/auth"; import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; import { accessScopeForUser } from "@shared/identity/access-scope"; import { normalizeAuthPhoneNumber } from "@shared/identity/phone-number"; +import { linqMessageContent } from "@agent/lib/linq/content"; import { prepareLinqImageArtifactDelivery } from "../lib/linq-image-artifact/delivery"; import { extractImageArtifactMarkdownReferences, @@ -36,14 +40,6 @@ const verifiedPhoneUserSchema = z.object({ const unavailableReplyTargetSchema = z.object({ status: z.union([z.literal(400), z.literal(404)]), }); -const incomingReplySchema = z.object({ - reply_to: z - .object({ - message_id: z.uuid(), - part_index: z.number().int().nonnegative().optional(), - }) - .nullish(), -}); type LinqMessageContent = Parameters< LinqAPIV3["chats"]["messages"]["send"] @@ -58,13 +54,17 @@ export const linqWebhookVerifier: NonNullable< LinqChannelCredentials["webhookVerifier"] > = async (request) => (await trustedForwarder(request)) ?? false; -const credentials = { - ...linqCredentials, +const linqAdapter = createLinqAdapter({ + credentials: async () => ({ apiKey: await linqCredentials.apiKey() }), webhookVerifier: env.LINQ_CONNECTOR ? linqWebhookVerifier : () => false, -} satisfies LinqChannelCredentials; +}); -export default linqChannel({ - credentials, +const linq = chatSdkChannel({ + adapters: { linq: linqAdapter }, + state: createMemoryState(), + userName: "eve", + concurrency: "concurrent", + streaming: false, events: { async "authorization.required"(event, context, session) { const { thread } = context; @@ -302,55 +302,59 @@ export default linqChannel({ await releaseScheduledReportDelivery(session, event.message); }, }, - async onMessage(context, message) { - if (message.author.isBot) return null; +}); - const auth = defaultLinqAuth(message); - const authorUserName = z.string().safeParse(message.author.userName); - const phoneNumber = authorUserName.success - ? normalizeAuthPhoneNumber(authorUserName.data) - : undefined; - const verifiedUserId = phoneNumber - ? await findVerifiedAuthUserIdByPhoneNumber(phoneNumber) - : undefined; - if (!verifiedUserId || !phoneNumber) { - // Phone possession is the only sign-in factor, so a handle that is not - // linked to a verified user is unauthenticated: never mint a principal - // or a workspace for it. - console.warn("[linq] ignoring message from an unlinked handle", { - threadId: context.thread.id, - }); - return null; - } - const principalId = `better-auth:${verifiedUserId}`; - const scope = accessScopeForUser(principalId); - const reply = incomingReplySchema.safeParse(message.raw); - const references = [ - `[Message: ${JSON.stringify({ messageId: message.id, sender: "user" })}]`, - ]; - if (reply.success && reply.data.reply_to) { - references.push( - `[Reply to: ${JSON.stringify({ messageId: reply.data.reply_to.message_id, partIndex: reply.data.reply_to.part_index })}]` - ); - } - return { - context: references, - auth: { - ...auth, - attributes: { - ...auth.attributes, - conversationChannel: "linq", - conversationId: context.thread.id, - linqThreadId: context.thread.id, - linqMessageId: message.id, - phoneNumber, - workspaceId: scope.workspaceId, - }, - principalId, +async function onMessage(thread: Thread, message: Message) { + if (message.author.isBot || message.author.isMe) return; + + const auth = defaultLinqAuth(message); + const authorUserName = z.string().safeParse(message.author.userName); + const phoneNumber = authorUserName.success + ? normalizeAuthPhoneNumber(authorUserName.data) + : undefined; + const verifiedUserId = phoneNumber + ? await findVerifiedAuthUserIdByPhoneNumber(phoneNumber) + : undefined; + if (!verifiedUserId || !phoneNumber) { + // Phone possession is the only sign-in factor, so a handle that is not + // linked to a verified user is unauthenticated: never mint a principal + // or a workspace for it. + console.warn("[linq] ignoring message from an unlinked handle", { + threadId: thread.id, + }); + return; + } + const principalId = `better-auth:${verifiedUserId}`; + const scope = accessScopeForUser(principalId); + const content = linqMessageContent(message); + if (!content.length) return; + try { + await linqAdapter.markRead(thread.id, message.id); + } catch { + // A read receipt must not prevent dispatch. + } + await linq.send(content, { + thread, + auth: { + ...auth, + attributes: { + ...auth.attributes, + conversationChannel: "linq", + conversationId: thread.id, + linqThreadId: thread.id, + linqMessageId: message.id, + phoneNumber, + workspaceId: scope.workspaceId, }, - }; - }, -}); + principalId, + }, + }); +} + +linq.bot.onDirectMessage(onMessage); +linq.bot.onNewMessage(/[\s\S]*/, onMessage); + +export default linq.channel; async function sendLinqMessage({ outgoing, diff --git a/agent/instructions/content/role/interactive.md b/agent/instructions/content/role/interactive.md index 6e050192..2e816a9e 100644 --- a/agent/instructions/content/role/interactive.md +++ b/agent/instructions/content/role/interactive.md @@ -52,7 +52,7 @@ The main conversation is the control plane. Coordinate the user's work there and - On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq renders reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible. - Use `schedules-create` to create a one-time reminder, recurring job, monitor, or scheduled follow-up. Use `kind: "calendar"` with the user's IANA timezone for wall-clock recurrence, `kind: "interval"` for elapsed intervals, and `kind: "once"` for one future instant. Summarize the exact task in `prompt`. Use `schedules-list` before changing an ambiguous schedule and `schedules-update` to edit, pause, resume, or delete it. - When the user answers a question previously sent for a scheduled task, call `schedules-answer` with the internal run ID retained in conversation context and their answer. The parked background run continues from the exact point where it asked. -- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` adds or removes one Unicode emoji on the supplied `messageId`. Use the quoted message's ID when reacting to an older reply target; never invent IDs. +- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. Use supplied `[Message]`, `[Parts]`, and `[Reply to]` references. `react_to_message` adds or removes one Unicode emoji on its actual target's `messageId`, current or earlier; never guess or expose IDs. - Eve and Linq own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation. - After a `send_message`, `share_contact`, or `react_to_message` call, never repeat or summarize it in assistant text. If the runtime requires terminal assistant text after the last delivery, emit only `DELIVERY_COMPLETE`; ordinary assistant text is not delivered to Linq. - The worker's structured result is coordinator-facing only. Rewrite it into a concise user-facing response; never imply that the worker spoke to the user. diff --git a/agent/lib/linq/content.test.ts b/agent/lib/linq/content.test.ts new file mode 100644 index 00000000..ab9e8945 --- /dev/null +++ b/agent/lib/linq/content.test.ts @@ -0,0 +1,90 @@ +import { Message } from "chat"; +import { describe, expect, it } from "vitest"; +import { linqMessageContent } from "./content"; + +describe("Linq message content", () => { + it("embeds indexed parts and the quoted target before the original text and media", () => { + const message = received({ + parts: [ + { type: "text", value: "this one" }, + { type: "media", url: "https://media.example/photo.png" }, + ], + reply_to: { message_id: "older-message", part_index: 1 }, + }); + message.attachments.push({ + type: "image", + mimeType: "image/png", + url: "https://media.example/photo.png", + }); + expect(linqMessageContent(message)).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-2","sender":"user"}]\n[Parts: [{"partIndex":0,"type":"text","value":"this one"},{"partIndex":1,"type":"media","url":"https://media.example/photo.png"}]]\n[Reply to: {"messageId":"older-message","partIndex":1}]', + }, + { type: "text", text: "this one" }, + expect.objectContaining({ type: "file", mediaType: "image/png" }), + ]); + }); + + it("preserves opaque app-card state when the adapter supplies no text or attachments", () => { + const raw = { + parts: [ + { + type: "imessage_app", + url: "data:application/json;base64,e30=", + app: { bundle_id: "example.game", name: "Game" }, + layout: { caption: "Your move" }, + fallback_text: "Game invite", + interactive: true, + }, + ], + }; + const message = received(raw); + message.text = ""; + const content = linqMessageContent(message); + expect(content).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-2","sender":"user"}]\n[Parts: [{"partIndex":0,"type":"imessage_app","url":"data:application/json;base64,e30=","app":{"bundle_id":"example.game","name":"Game"},"layout":{"caption":"Your move"},"fallback_text":"Game invite","interactive":true}]]', + }, + { type: "text", text: "[iMessage app card]" }, + ]); + expect(message.raw).toBe(raw); + expect(message.text).toBe(""); + }); + + it("keeps original content when the raw metadata is malformed", () => { + expect(linqMessageContent(received({ parts: "invalid" }))).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-2","sender":"user"}]', + }, + { type: "text", text: "this one" }, + ]); + }); + + it("does not turn an empty incoming message into a metadata-only turn", () => { + const message = received({ parts: [] }); + message.text = ""; + expect(linqMessageContent(message)).toEqual([]); + }); +}); + +function received(raw: Message["raw"]) { + return new Message({ + id: "message-2", + threadId: "linq:chat-1", + raw, + text: "this one", + attachments: [], + formatted: { type: "root", children: [] }, + author: { + userId: "sender-1", + userName: "+15550100011", + fullName: "Sender", + isBot: false, + isMe: false, + }, + metadata: { dateSent: new Date("2026-10-05T12:00:00Z"), edited: false }, + }); +} diff --git a/agent/lib/linq/content.ts b/agent/lib/linq/content.ts new file mode 100644 index 00000000..c712755a --- /dev/null +++ b/agent/lib/linq/content.ts @@ -0,0 +1,50 @@ +import { messageToUserContent } from "eve/channels/chat-sdk"; +import { z } from "zod"; +import type { Message } from "chat"; + +const messageReferencesSchema = z.object({ + parts: z + .array( + z.object({ + type: z.string(), + value: z.string().optional(), + url: z.string().optional(), + app: z.unknown().optional(), + layout: z.unknown().optional(), + fallback_text: z.string().nullish(), + interactive: z.boolean().optional(), + }) + ) + .optional(), + reply_to: z + .object({ + message_id: z.string().optional(), + part_index: z.number().int().nonnegative().optional(), + }) + .nullish(), +}); + +export function linqMessageContent(message: Message) { + const parsed = messageReferencesSchema.safeParse(message.raw); + const references = parsed.success ? parsed.data : undefined; + const hasApp = references?.parts?.some( + (part) => part.type === "imessage_app" + ); + if (!message.text.trim() && !message.attachments.length && !hasApp) return []; + const content = messageToUserContent(message); + const parts = ( + Array.isArray(content) + ? content + : [{ type: "text" as const, text: content }] + ).filter((part) => part.type !== "text" || part.text.length > 0); + let label = `[Message: ${JSON.stringify({ messageId: message.id, sender: message.author.isMe ? "openinstinct" : "user" })}]`; + if (references?.parts) { + label += `\n[Parts: ${JSON.stringify(references.parts.map((part, partIndex) => ({ partIndex, ...part })))}]`; + } + if (references?.reply_to?.message_id) { + label += `\n[Reply to: ${JSON.stringify({ messageId: references.reply_to.message_id, partIndex: references.reply_to.part_index })}]`; + } + if (hasApp && !message.text.trim()) + parts.push({ type: "text", text: "[iMessage app card]" }); + return [{ type: "text" as const, text: label }, ...parts]; +} diff --git a/evals/agent/conversation.eval.ts b/evals/agent/conversation.eval.ts index f39b7587..d9f4c629 100644 --- a/evals/agent/conversation.eval.ts +++ b/evals/agent/conversation.eval.ts @@ -96,6 +96,44 @@ const textEvals = cases.map((testCase) => ); const reactionEvals = [ + defineEval({ + description: + "Retains message IDs and indexed part metadata for later turns", + tags: [...agentEvalTags, "conversation", "reaction", "message-history"], + async test(t) { + const messageId = "00000000-0000-4000-8000-000000000001"; + const url = "https://media.example/second-photo.png"; + const first = await t.send([ + { + type: "text", + text: `[Message: ${JSON.stringify({ messageId, sender: "user" })}]\n[Parts: ${JSON.stringify( + [ + { partIndex: 0, type: "text", value: "two photos" }, + { + partIndex: 1, + type: "media", + url: "https://media.example/first-photo.png", + }, + { partIndex: 2, type: "media", url }, + ] + )}]`, + }, + { type: "text", text: "I sent two photos. Just reply noted." }, + ]); + first.expectOk(); + await requireDeliveredText(t, first); + const recalled = await first.session.send( + "From my previous message's metadata, give the message ID and the URL for partIndex 2 as plain text. Do not open the URL." + ); + recalled.expectOk(); + recalled.succeeded(); + const text = await requireDeliveredText(t, recalled); + t.check(text, includes(messageId)); + t.check(text, includes(url)); + recalled.notCalledTool("run_browser"); + recalled.notCalledTool("web_fetch"); + }, + }), defineEval({ description: "Uses a reaction for a lightweight acknowledgement", tags: [...agentEvalTags, "conversation", "reaction", "smoke"], @@ -137,12 +175,13 @@ const reactionEvals = [ tags: [...agentEvalTags, "conversation", "reaction", "reaction-target"], async test(t) { const older = "00000000-0000-4000-8000-000000000001"; - const turn = await t.send("like this old message", { - clientContext: [ - '[Message: {"messageId":"00000000-0000-4000-8000-000000000002","sender":"user"}]', - `[Reply to: ${JSON.stringify({ messageId: older })}]`, - ], - }); + const turn = await t.send([ + { + type: "text", + text: `[Message: {"messageId":"00000000-0000-4000-8000-000000000002","sender":"user"}]\n[Reply to: ${JSON.stringify({ messageId: older })}]`, + }, + { type: "text", text: "like this old message" }, + ]); turn.expectOk(); turn.succeeded(); turn.calledTool("react_to_message", { @@ -167,11 +206,13 @@ const reactionEvals = [ tags: [...agentEvalTags, "conversation", "reaction", "reaction-target"], async test(t) { const messageId = "00000000-0000-4000-8000-000000000002"; - const turn = await t.send("React to this message with πŸ‘€", { - clientContext: [ - `[Message: ${JSON.stringify({ messageId, sender: "user" })}]`, - ], - }); + const turn = await t.send([ + { + type: "text", + text: `[Message: ${JSON.stringify({ messageId, sender: "user" })}]`, + }, + { type: "text", text: "React to this message with πŸ‘€" }, + ]); turn.expectOk(); turn.succeeded(); turn.calledTool("react_to_message", { diff --git a/package.json b/package.json index 19bbbacb..4ca00201 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@better-auth/core": "1.7.5", + "@chat-adapter/state-memory": "4.41.0", "@googleapis/calendar": "^16.0.0", "@googleapis/gmail": "^18.0.0", "@googleapis/people": "^8.0.0", @@ -30,6 +31,7 @@ "@vercel/oidc": "3.8.5", "ai": "^7.0.105", "better-auth": "1.7.5", + "chat": "4.41.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -59,7 +61,6 @@ "@types/pg": "^8.23.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", - "chat": "4.41.0", "drizzle-kit": "^0.31.10", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-turbo": "2.10.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 153de209..4e9c7dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@better-auth/core': specifier: 1.7.5 version: 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@chat-adapter/state-memory': + specifier: 4.41.0 + version: 4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4) '@googleapis/calendar': specifier: ^16.0.0 version: 16.0.0 @@ -96,6 +99,9 @@ importers: better-auth: specifier: 1.7.5 version: 1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + chat: + specifier: 4.41.0 + version: 4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -178,9 +184,6 @@ importers: '@types/react-dom': specifier: 19.2.5 version: 19.2.5(@types/react@19.2.18) - chat: - specifier: 4.41.0 - version: 4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -612,6 +615,10 @@ packages: '@bytecodealliance/preview2-shim@0.17.6': resolution: {integrity: sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==} + '@chat-adapter/state-memory@4.41.0': + resolution: {integrity: sha512-Px+csQMhc0/Z1SauPdzV8ar9GJ4EoCThFi2a+e3HcrcYTdwLSNhT/h4MJrfaA3/8PCOO4Y4EvanwGVYzcLbhhA==} + engines: {node: '>=20'} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -7840,6 +7847,15 @@ snapshots: '@bytecodealliance/preview2-shim@0.17.6': {} + '@chat-adapter/state-memory@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)': + dependencies: + chat: 4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4) + transitivePeerDependencies: + - ai + - supports-color + - workflow + - zod + '@chevrotain/types@11.1.2': {} '@dotenvx/dotenvx@1.75.1': diff --git a/tests/agent/channels/linq-inbound-auth.test.ts b/tests/agent/channels/linq-inbound-auth.test.ts index 044db535..5a2139c4 100644 --- a/tests/agent/channels/linq-inbound-auth.test.ts +++ b/tests/agent/channels/linq-inbound-auth.test.ts @@ -1,38 +1,51 @@ -import type { LinqChannelConfig } from "eve/channels/linq"; +import type * as ChatSdkModule from "eve/channels/chat-sdk"; +import type { + ChatSdkChannelConfig, + ChatSdkChannelBridge, +} from "eve/channels/chat-sdk"; +import type { createLinqAdapter } from "@linqapp/chat-sdk-adapter"; import { Message } from "chat"; +import type { Thread } from "chat"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as EnvModule from "@shared/environment"; -// oxlint-disable-next-line import/no-unassigned-import -- Loads the production module so the mocked channel factory can capture its configuration. -import "@agent/channels/linq"; - -interface AuthUserRow { - readonly id: string; - readonly phoneNumberVerified: boolean; -} +import { linqWebhookVerifier } from "@agent/channels/linq"; +type LinqBridge = ChatSdkChannelBridge<{ + linq: ReturnType; +}>; const capture = vi.hoisted(() => ({ - // SAFETY: The mocked channel factory replaces this value during module loading. - config: undefined as LinqChannelConfig | undefined, - findOne: vi.fn<() => Promise>(), + // SAFETY: The actual bridge's handler registration supplies this callback. + onMessage: undefined as + | Parameters[1] + | undefined, + findOne: + vi.fn<() => Promise<{ id: string; phoneNumberVerified: boolean } | null>>(), + send: vi.fn(), })); - vi.mock("@shared/environment", async (importOriginal) => { const original = await importOriginal(); - return { - ...original, - env: { ...original.env, LINQ_CONNECTOR: "linq/test" }, - }; + return { ...original, env: { ...original.env, LINQ_CONNECTOR: "linq/test" } }; }); vi.mock("@vercel/connect/eve", () => ({ connectLinqCredentials: () => ({ apiKey: async () => "linq-test-api-key" }), })); -vi.mock(import("eve/channels/linq"), async (importOriginal) => { - const original = await importOriginal(); +vi.mock("eve/channels/chat-sdk", async (importOriginal) => { + const original = await importOriginal(); return { ...original, - linqChannel(config: LinqChannelConfig) { - capture.config = config; - return original.linqChannel(config); + chatSdkChannel( + config: ChatSdkChannelConfig<{ + linq: ReturnType; + }> + ) { + const bridge = original.chatSdkChannel(config); + vi.spyOn(bridge.bot, "onNewMessage").mockImplementation( + (_pattern, handler) => { + capture.onMessage = handler; + } + ); + vi.spyOn(config.adapters.linq, "markRead").mockResolvedValue(undefined); + return { ...bridge, send: capture.send }; }, }; }); @@ -41,91 +54,88 @@ vi.mock("@db/services/auth", () => ({ $context: Promise.resolve({ adapter: { findOne: capture.findOne } }), }), })); - -const verifier = capture.config?.credentials?.webhookVerifier; -const onMessage = capture.config?.onMessage; -if (!verifier || !onMessage) { - throw new Error("The Linq channel must verify webhooks and route messages."); -} +const onMessage = capture.onMessage; +if (!onMessage) + throw new Error("The Linq bridge must register its inbound handler."); describe("Linq inbound authentication", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("rejects a webhook without a forwarder credential", async () => { const request = new Request("https://assistant.example/eve/v1/linq", { body: "{}", method: "POST", }); - await expect(verifier(request, new Uint8Array())).resolves.toBe(false); + await expect(linqWebhookVerifier(request, new Uint8Array())).resolves.toBe( + false + ); }); - it("rejects a webhook with a malformed bearer token", async () => { const request = new Request("https://assistant.example/eve/v1/linq", { body: "{}", headers: { authorization: "Bearer aaa.bbb.ccc" }, method: "POST", }); - await expect(verifier(request, new Uint8Array())).resolves.toBe(false); + await expect(linqWebhookVerifier(request, new Uint8Array())).resolves.toBe( + false + ); }); - - it("drops messages from handles that are not linked to a verified user", async () => { + it("drops unlinked handles before dispatch", async () => { capture.findOne.mockResolvedValue(null); - - await expect( - onMessage(threadContext(), linqMessage("+15550100011")) - ).resolves.toBeNull(); + await onMessage(thread(), linqMessage("+15550100011")); + expect(capture.send).not.toHaveBeenCalled(); expect(capture.findOne).toHaveBeenCalledExactlyOnceWith({ model: "user", where: [{ field: "phoneNumber", value: "+15550100011" }], }); }); - - it("drops messages from handles whose user has not verified the phone", async () => { + it("drops handles whose user has not verified the phone", async () => { capture.findOne.mockResolvedValue({ id: "user-1", phoneNumberVerified: false, }); - - await expect( - onMessage(threadContext(), linqMessage("+15550100011")) - ).resolves.toBeNull(); + await onMessage(thread(), linqMessage("+15550100011")); + expect(capture.send).not.toHaveBeenCalled(); }); - - it("scopes a verified handle to that user's own workspace", async () => { + it("scopes a verified handle and embeds annotations in the dispatched message", async () => { capture.findOne.mockResolvedValue({ id: "user-1", phoneNumberVerified: true, }); - - const result = await onMessage( - threadContext(), - linqMessage("+15550100011") + await onMessage( + thread(), + linqMessage("+15550100011", { + parts: [{ type: "text", value: "list my vault items" }], + }) ); - - expect(result?.auth?.principalId).toBe("better-auth:user-1"); - expect(result?.auth?.attributes).toMatchObject({ + expect(capture.send).toHaveBeenCalledOnce(); + const [content, options] = capture.send.mock.calls[0] ?? []; + expect(content).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-1","sender":"user"}]\n[Parts: [{"partIndex":0,"type":"text","value":"list my vault items"}]]', + }, + { type: "text", text: "list my vault items" }, + ]); + expect(options?.auth?.principalId).toBe("better-auth:user-1"); + expect(options?.auth?.attributes).toMatchObject({ conversationChannel: "linq", - conversationId: "linq:dm:chat-1", + conversationId: "linq:chat-1", linqMessageId: "message-1", phoneNumber: "+15550100011", }); - expect(result?.auth?.attributes.workspaceId).toMatch( + expect(options?.auth?.attributes.workspaceId).toMatch( /^personal:[0-9a-f]{32}$/ ); - expect(result?.context).toEqual([ - '[Message: {"messageId":"message-1","sender":"user"}]', - ]); }); - - it("supplies the older quoted message ID alongside the incoming reply", async () => { + it("embeds the older quoted message and part index in the same content label", async () => { capture.findOne.mockResolvedValue({ id: "user-1", phoneNumberVerified: true, }); - const result = await onMessage( - threadContext(), + await onMessage( + thread(), linqMessage("+15550100011", { reply_to: { message_id: "00000000-0000-4000-8000-000000000002", @@ -133,45 +143,38 @@ describe("Linq inbound authentication", () => { }, }) ); - expect(result?.context).toEqual([ - '[Message: {"messageId":"message-1","sender":"user"}]', - '[Reply to: {"messageId":"00000000-0000-4000-8000-000000000002","partIndex":2}]', + expect(capture.send.mock.calls[0]?.[0]).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-1","sender":"user"}]\n[Reply to: {"messageId":"00000000-0000-4000-8000-000000000002","partIndex":2}]', + }, + { type: "text", text: "list my vault items" }, ]); - expect(result?.auth?.attributes.linqMessageId).toBe("message-1"); }); - - it("does not present a malformed reply target as a usable message ID", async () => { + it("keeps the body and current ID if raw references are malformed", async () => { capture.findOne.mockResolvedValue({ id: "user-1", phoneNumberVerified: true, }); - const result = await onMessage( - threadContext(), - linqMessage("+15550100011", { - reply_to: { message_id: "invented-message", part_index: -1 }, - }) + await onMessage( + thread(), + linqMessage("+15550100011", { reply_to: { part_index: -1 } }) ); - expect(result?.context).toEqual([ - '[Message: {"messageId":"message-1","sender":"user"}]', + expect(capture.send.mock.calls[0]?.[0]).toEqual([ + { + type: "text", + text: '[Message: {"messageId":"message-1","sender":"user"}]', + }, + { type: "text", text: "list my vault items" }, ]); }); }); -type InboundContext = Parameters< - NonNullable ->[0]; - -interface ThreadIdentity { - readonly thread: Pick; +function thread(): Thread { + // SAFETY: Inbound routing and the mocked send boundary read only this thread ID. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Constructing a complete SDK thread adds unrelated provider operations. + return { id: "linq:chat-1" } as Thread; } - -function threadContext(): InboundContext { - const identity: ThreadIdentity = { thread: { id: "linq:dm:chat-1" } }; - // SAFETY: The inbound policy reads only the thread id from this context. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- A complete Chat SDK thread mock would add unrelated methods. - return identity as InboundContext; -} - function linqMessage(handle: string, raw: Message["raw"] = {}) { return new Message({ attachments: [], @@ -184,12 +187,9 @@ function linqMessage(handle: string, raw: Message["raw"] = {}) { }, formatted: { children: [], type: "root" }, id: "message-1", - metadata: { - dateSent: new Date("2026-09-03T00:00:00.000Z"), - edited: false, - }, + metadata: { dateSent: new Date("2026-09-03T00:00:00.000Z"), edited: false }, raw, text: "list my vault items", - threadId: "linq:dm:chat-1", + threadId: "linq:chat-1", }); } diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index 3b477995..ada8fd07 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -1,4 +1,5 @@ -import type { LinqChannelConfig } from "eve/channels/linq"; +import type * as ChatSdkModule from "eve/channels/chat-sdk"; +import type { ChatSdkChannelConfig } from "eve/channels/chat-sdk"; import { createLinqAdapter, type LinqSendOptions, @@ -34,7 +35,9 @@ const linqChannelCapture = vi.hoisted(() => ({ // SAFETY: This mutable test capture stores only API keys from the typed SDK constructor mock. clientApiKeys: [] as string[], // SAFETY: The mocked channel factory replaces this value during module loading. - config: undefined as LinqChannelConfig | undefined, + config: undefined as + | ChatSdkChannelConfig<{ linq: ReturnType }> + | undefined, images: new Map(), readImage: vi.fn< ( @@ -99,13 +102,17 @@ vi.mock("@linqapp/sdk", () => ({ }; }, })); -vi.mock(import("eve/channels/linq"), async (importOriginal) => { - const original = await importOriginal(); +vi.mock("eve/channels/chat-sdk", async (importOriginal) => { + const original = await importOriginal(); return { ...original, - linqChannel(config: LinqChannelConfig) { + chatSdkChannel( + config: ChatSdkChannelConfig<{ + linq: ReturnType; + }> + ) { linqChannelCapture.config = config; - return original.linqChannel(config); + return original.chatSdkChannel(config); }, }; }); From 77f42385e3f41879b7f559d0b4c91a0876c6b9f2 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Mon, 5 Oct 2026 16:47:42 -0400 Subject: [PATCH 3/4] Render provider references and reactions in chat history --- agent/lib/linq/content.ts | 37 +-- .../_components/conversation/index.test.tsx | 37 +++ .../_components/conversation/index.tsx | 14 +- .../conversation/message/index.tsx | 71 ++++- .../chat/[sessionId]/_lib/message-events.ts | 8 +- .../_lib/message-presentation.test.ts | 290 ++++++++++++++++++ .../[sessionId]/_lib/message-presentation.ts | 145 +++++++++ shared/chat/message-context.test.ts | 34 ++ shared/chat/message-context.ts | 62 ++++ tests/fixtures/message-history.ts | 202 ++++++++++++ 10 files changed, 876 insertions(+), 24 deletions(-) create mode 100644 app/(authenticated)/chat/[sessionId]/_lib/message-presentation.test.ts create mode 100644 app/(authenticated)/chat/[sessionId]/_lib/message-presentation.ts create mode 100644 shared/chat/message-context.test.ts create mode 100644 shared/chat/message-context.ts create mode 100644 tests/fixtures/message-history.ts diff --git a/agent/lib/linq/content.ts b/agent/lib/linq/content.ts index c712755a..74195124 100644 --- a/agent/lib/linq/content.ts +++ b/agent/lib/linq/content.ts @@ -1,20 +1,14 @@ import { messageToUserContent } from "eve/channels/chat-sdk"; import { z } from "zod"; import type { Message } from "chat"; +import { + formatMessageContext, + messagePartReferenceSchema, +} from "@shared/chat/message-context"; const messageReferencesSchema = z.object({ parts: z - .array( - z.object({ - type: z.string(), - value: z.string().optional(), - url: z.string().optional(), - app: z.unknown().optional(), - layout: z.unknown().optional(), - fallback_text: z.string().nullish(), - interactive: z.boolean().optional(), - }) - ) + .array(messagePartReferenceSchema.omit({ partIndex: true })) .optional(), reply_to: z .object({ @@ -37,13 +31,20 @@ export function linqMessageContent(message: Message) { ? content : [{ type: "text" as const, text: content }] ).filter((part) => part.type !== "text" || part.text.length > 0); - let label = `[Message: ${JSON.stringify({ messageId: message.id, sender: message.author.isMe ? "openinstinct" : "user" })}]`; - if (references?.parts) { - label += `\n[Parts: ${JSON.stringify(references.parts.map((part, partIndex) => ({ partIndex, ...part })))}]`; - } - if (references?.reply_to?.message_id) { - label += `\n[Reply to: ${JSON.stringify({ messageId: references.reply_to.message_id, partIndex: references.reply_to.part_index })}]`; - } + const label = formatMessageContext({ + messageId: message.id, + sender: message.author.isMe ? "openinstinct" : "user", + parts: references?.parts?.map((part, partIndex) => ({ + partIndex, + ...part, + })), + replyTo: references?.reply_to?.message_id + ? { + messageId: references.reply_to.message_id, + partIndex: references.reply_to.part_index, + } + : undefined, + }); if (hasApp && !message.text.trim()) parts.push({ type: "text", text: "[iMessage app card]" }); return [{ type: "text" as const, text: label }, ...parts]; diff --git a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx index 46388d23..f190f5c0 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx @@ -4,8 +4,45 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { ChatConversation } from "."; import type { ChatAgent } from "../chat-agent"; +import { defaultMessageReducer } from "eve/client"; +import { messageHistoryEvents } from "@tests/fixtures/message-history"; describe("chat conversation", () => { + it("renders new provider history, attachments, quoted parts, and targeted reactions", () => { + const reducer = defaultMessageReducer(); + const data = messageHistoryEvents.reduce( + (state, event) => reducer.reduce(state, event), + reducer.initial() + ); + const agent = { + data, + events: messageHistoryEvents, + error: undefined, + respond: async () => undefined, + status: "ready" as const, + }; + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("Here are two photos."); + expect(markup).toContain('aria-label="Open orange.svg"'); + expect(markup).toContain('aria-label="Open blue.svg"'); + expect(markup).toContain('aria-label="Reply to blue.svg"'); + expect(markup).toContain('href="#photos%3Areceived%3Auser"'); + expect(markup).toContain('aria-label="Reaction πŸ‘"'); + expect(markup).toContain('aria-label="Reaction πŸ‘©πŸ½β€πŸ’»"'); + expect(markup).toContain('aria-label="Reaction ❀️"'); + expect(markup).not.toContain('aria-label="Reaction πŸ‘€"'); + expect(markup).not.toContain("11111111-1111-4111-8111-111111111111"); + expect(markup).toContain("literal-parts-example"); + expect(markup).not.toContain("opaque-app-payload"); + expect(markup).toContain("example"); + const trace = renderToStaticMarkup( + + ); + expect(trace).toContain("[Parts:"); + expect(trace).toContain("opaque-app-payload"); + }); it("shows send_message output instead of assistant stream text", () => { const agent = { data: { diff --git a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.tsx b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.tsx index 0e95fc79..f7e220a7 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.tsx @@ -7,6 +7,7 @@ import { } from "../../_lib/message-events"; import { messagesForTraceView, type TraceView } from "../../_lib/trace-view"; import { getLatestTurnFailure } from "../../_lib/turn-failure"; +import { messagePresentations } from "../../_lib/message-presentation"; import { Conversation, ConversationContent, @@ -69,9 +70,13 @@ export function ChatConversation({ : messageTimestamps(agent.events), [agent.events, traceView] ); + const { presentations, handledReactionCallIds } = useMemo( + () => messagePresentations(messages, agent.events), + [messages, agent.events] + ); const deliveredMessages = useMemo( - () => sentMessages(agent.events), - [agent.events] + () => sentMessages(agent.events, handledReactionCallIds), + [agent.events, handledReactionCallIds] ); return ( @@ -143,6 +148,11 @@ export function ChatConversation({ key={message.id} message={message} onInputResponses={(responses) => agent.respond(responses)} + presentation={ + traceView === "imessage" + ? presentations.get(message.id) + : undefined + } timestamp={timestamps.get(message.id)} userVisibleOnly={traceView === "imessage"} /> diff --git a/app/(authenticated)/chat/[sessionId]/_components/conversation/message/index.tsx b/app/(authenticated)/chat/[sessionId]/_components/conversation/message/index.tsx index 325dd9ea..09d4013f 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/conversation/message/index.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/conversation/message/index.tsx @@ -6,12 +6,14 @@ import { Message, MessageContent } from "@web/components/ai-elements/message"; import { cn } from "@web/components/class-names"; import { AgentMessagePart, partKey } from "./parts"; import type { RespondToAgentInput } from "./types"; +import type { MessagePresentation } from "../../../_lib/message-presentation"; export function AgentMessage({ canRespond, isStreaming, message, onInputResponses, + presentation, sentMessageParts, timestamp, userVisibleOnly = false, @@ -20,6 +22,7 @@ export function AgentMessage({ readonly isStreaming: boolean; readonly message: EveMessage; readonly onInputResponses: RespondToAgentInput; + readonly presentation?: MessagePresentation; readonly sentMessageParts?: readonly EveMessage["parts"][number][]; readonly timestamp?: string; readonly userVisibleOnly?: boolean; @@ -28,7 +31,7 @@ export function AgentMessage({ const displayedTimestamp = timestamp ?? (message.role === "user" ? optimisticTimestamp : undefined); const visibleParts = userVisibleOnly - ? userVisibleParts(message, sentMessageParts) + ? userVisibleParts(message, sentMessageParts, presentation) : message.parts; const lastTextIndex = visibleParts.reduce( (last, part, index) => (part.type === "text" ? index : last), @@ -42,10 +45,14 @@ export function AgentMessage({ return ( + {presentation?.reply ? ( + + ) : null} {visibleParts.map((part, index) => hasAssistantText && part.type === "reasoning" ? null : ( + {presentation?.reactions.length ? ( +
    + {presentation.reactions.map((emoji) => ( +
  • + {emoji} +
  • + ))} +
+ ) : null} {displayedTimestamp ? (