From df64b384892a3f0c4908902e0c7cd873bbea3c46 Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:51:10 -0400 Subject: [PATCH 1/4] feat: add semantic Linq reply handles --- agent/channels/linq.ts | 136 +- agent/hooks/background-reply-target.ts | 12 + agent/lib/reply-targets.ts | 83 + agent/lib/schedules/identity.ts | 4 + agent/lib/schedules/report.ts | 57 +- agent/lib/schedules/tools.ts | 7 + agent/lib/send-message.ts | 42 +- agent/tools/messaging.ts | 39 +- agent/tools/schedules.ts | 2 + db/migrations/0011_jazzy_zarda.sql | 1 + db/migrations/meta/0011_snapshot.json | 1943 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/schedules.ts | 1 + db/services/scheduled-agent-jobs.ts | 2 + db/tests/database-migration.test.ts | 1 + db/tests/scheduled-agent-jobs.test.ts | 1 + evals/agent/conversation.eval.ts | 191 +- .../[sessionId]/_lib/message-events.test.ts | 24 + .../channels/eve-message-delivery.test.ts | 1 + .../agent/channels/linq-inbound-auth.test.ts | 1 + .../channels/linq-message-delivery.test.ts | 196 +- tests/agent/reply-targets.test.ts | 183 ++ tests/agent/schedules/dynamic.test.ts | 13 +- tests/agent/tools/schedules.test.ts | 31 + 24 files changed, 2909 insertions(+), 69 deletions(-) create mode 100644 agent/hooks/background-reply-target.ts create mode 100644 agent/lib/reply-targets.ts create mode 100644 db/migrations/0011_jazzy_zarda.sql create mode 100644 db/migrations/meta/0011_snapshot.json create mode 100644 tests/agent/reply-targets.test.ts diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index c3b82720..52195692 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -10,6 +10,7 @@ import { vercelOidc } from "eve/channels/auth"; import { z } from "zod"; import { getAuth } from "@/auth"; import { reactToMessageToolResultSchema } from "@/agent/lib/react-to-message"; +import { resolveLinqReplyTarget } from "@/agent/lib/reply-targets"; import { sendMessageToolResultSchema } from "@/agent/lib/send-message"; import { normalizeAuthPhoneNumber } from "@/auth/phone-number"; import { scopeFromPrincipal } from "@/agent/lib/principal-scope"; @@ -30,6 +31,13 @@ const verifiedPhoneUserSchema = z.object({ id: z.string().min(1), phoneNumberVerified: z.literal(true), }); +const unavailableReplyTargetSchema = z.object({ + status: z.union([z.literal(400), z.literal(404)]), +}); + +type LinqMessageContent = Parameters< + LinqAPIV3["chats"]["messages"]["send"] +>[1]["message"]; const trustedForwarder = vercelOidc(); @@ -98,6 +106,14 @@ export default linqChannel({ ); } const report = scheduledReportFromSession(session); + const replyTarget = resolveLinqReplyTarget( + message.data.output.replyTo, + session.session.auth + ); + const requestedReplyMessageId = + replyTarget?.conversationId === thread.id + ? replyTarget.messageId + : undefined; const idempotencyKey = report ? `scheduled-report:${report.runId}:${String(report.sequence)}` : undefined; @@ -107,26 +123,50 @@ export default linqChannel({ .getAdapter("linq") .postMessage(thread.id, content, { idempotencyKey }) : (content: AdapterPostableMessage) => thread.post(content); - - if (message.data.output.kind === "link") { + const resolveExistingChatId = () => { const adapter = context.bot.getAdapter("linq"); const { chatId, pendingHandle } = adapter.decodeThreadId(thread.id); if (pendingHandle || !chatId) { - throw new Error( - "A native link preview requires an existing Linq conversation." - ); + throw new Error("A Linq reply requires an existing conversation."); } + return chatId; + }; + + if (message.data.output.kind === "link") { + const { url } = message.data.output; + const chatId = resolveExistingChatId(); const apiKey = await credentials.apiKey(); const client = new LinqAPIV3({ apiKey }); - await client.chats.messages.send( - chatId, - { - message: { - parts: [{ type: "link", value: message.data.output.url }], - }, - }, - idempotencyKey ? { idempotencyKey } : undefined - ); + const sendLink = (replyToMessageId?: string) => { + const nativeMessage: LinqMessageContent = { + parts: [{ type: "link", value: url }], + }; + if (idempotencyKey) { + nativeMessage.idempotency_key = idempotencyKey; + } + if (replyToMessageId) { + nativeMessage.reply_to = { message_id: replyToMessageId }; + } + return client.chats.messages.send( + chatId, + { message: nativeMessage }, + undefined + ); + }; + try { + await sendLink(requestedReplyMessageId); + } catch (error) { + if ( + !requestedReplyMessageId || + !unavailableReplyTargetSchema.safeParse(error).success + ) { + throw error; + } + console.warn("[linq] reply target is unavailable", { + sessionId: session.session.id, + }); + await sendLink(); + } await finalizeScheduledReportDelivery(session); return; } @@ -138,6 +178,8 @@ export default linqChannel({ if (!requestedText) { if (attachments?.length) { await post({ attachments, raw: "" }); + await finalizeScheduledReportDelivery(session); + return; } await finalizeScheduledReportDelivery(session); return; @@ -162,7 +204,14 @@ export default linqChannel({ { raw: string } > = { raw: text }; if (attachments?.length) outgoing.attachments = attachments; - await post(outgoing); + await sendLinqMessage({ + idempotencyKey, + outgoing, + post, + resolveExistingChatId, + replyToMessageId: + attachments?.length === 0 ? requestedReplyMessageId : undefined, + }); await finalizeScheduledReportDelivery(session); return; } @@ -192,7 +241,16 @@ export default linqChannel({ > = { raw: text }; if (attachments?.length) outgoing.attachments = attachments; if (delivery.files.length > 0) outgoing.files = delivery.files; - await post(outgoing); + await sendLinqMessage({ + idempotencyKey, + outgoing, + post, + resolveExistingChatId, + replyToMessageId: + !attachments?.length && delivery.files.length === 0 + ? requestedReplyMessageId + : undefined, + }); await finalizeScheduledReportDelivery(session); } }, @@ -249,6 +307,7 @@ export default linqChannel({ conversationChannel: "linq", conversationId: context.thread.id, linqThreadId: context.thread.id, + linqMessageId: message.id, phoneNumber, workspaceId: scope.workspaceId, }, @@ -258,6 +317,51 @@ export default linqChannel({ }, }); +async function sendLinqMessage({ + idempotencyKey, + outgoing, + post, + resolveExistingChatId, + replyToMessageId, +}: { + readonly idempotencyKey?: string; + readonly outgoing: Extract; + readonly post: ( + content: AdapterPostableMessage + ) => Promise<{ readonly id: string }>; + readonly resolveExistingChatId: () => string; + readonly replyToMessageId?: string; +}) { + if (!replyToMessageId) { + await post(outgoing); + return; + } + const chatId = resolveExistingChatId(); + const apiKey = await credentials.apiKey(); + const client = new LinqAPIV3({ apiKey }); + try { + const nativeMessage: LinqMessageContent = { + parts: [{ type: "text", value: outgoing.raw }], + reply_to: { message_id: replyToMessageId }, + }; + if (idempotencyKey) { + nativeMessage.idempotency_key = idempotencyKey; + } + await client.chats.messages.send( + chatId, + { message: nativeMessage }, + undefined + ); + return; + } catch (error) { + if (!unavailableReplyTargetSchema.safeParse(error).success) throw error; + console.warn("[linq] reply target is unavailable", { + replyToMessageId, + }); + await post(outgoing); + } +} + async function findVerifiedAuthUserIdByPhoneNumber(phoneNumber: string) { const auth = await getAuth(); const context = await auth.$context; diff --git a/agent/hooks/background-reply-target.ts b/agent/hooks/background-reply-target.ts new file mode 100644 index 00000000..4dd777d3 --- /dev/null +++ b/agent/hooks/background-reply-target.ts @@ -0,0 +1,12 @@ +import { defineHook } from "eve/hooks"; +import { registerBackgroundReplyTarget } from "@/agent/lib/reply-targets"; + +export default defineHook({ + events: { + "subagent.completed"(event, context) { + const task = event.data.backgroundTask; + if (!task) return; + registerBackgroundReplyTarget(task.taskId, context.session.auth); + }, + }, +}); diff --git a/agent/lib/reply-targets.ts b/agent/lib/reply-targets.ts new file mode 100644 index 00000000..c3345b63 --- /dev/null +++ b/agent/lib/reply-targets.ts @@ -0,0 +1,83 @@ +import { defineState, type SessionAuth } from "eve/context"; +import { z } from "zod"; +import type { ReplyReference } from "@/agent/lib/send-message"; +import { scheduledReportIdentity } from "@/agent/lib/schedules/identity"; + +const linqReplyTargetSchema = z.strictObject({ + conversationId: z.string().startsWith("linq:"), + messageId: z.string().min(1), +}); + +type LinqReplyTarget = z.infer; + +const backgroundReplyTargets = defineState>( + "open-instinct.background-reply-targets", + () => ({}) +); + +const maximumBackgroundReplyTargets = 100; + +export function registerBackgroundReplyTarget( + taskId: string, + auth: SessionAuth +) { + const target = currentLinqReplyTarget(auth); + if (!target) return; + + backgroundReplyTargets.update((current) => + Object.fromEntries( + [ + ...Object.entries(current).filter(([id]) => id !== taskId), + [taskId, target] as const, + ].slice(-maximumBackgroundReplyTargets) + ) + ); +} + +export function resolveLinqReplyTarget( + reference: ReplyReference | undefined, + auth: SessionAuth +) { + if (!reference) return undefined; + + const conversationId = currentLinqConversationId(auth); + if (!conversationId) return undefined; + + if (reference.kind === "current") { + return currentLinqReplyTarget(auth); + } + + if (reference.kind === "task") { + const target = backgroundReplyTargets.get()[reference.id]; + return target?.conversationId === conversationId ? target : undefined; + } + + const report = scheduledReportIdentity(auth); + if (report?.scheduleId !== reference.id || !report.replyAnchorMessageId) { + return undefined; + } + return { + conversationId, + messageId: report.replyAnchorMessageId, + } satisfies LinqReplyTarget; +} + +function currentLinqConversationId(auth: SessionAuth) { + const caller = auth.current ?? auth.initiator; + if (caller?.attributes.conversationChannel !== "linq") return undefined; + const parsed = z + .string() + .startsWith("linq:") + .safeParse(caller.attributes.conversationId); + return parsed.success ? parsed.data : undefined; +} + +function currentLinqReplyTarget(auth: SessionAuth) { + const caller = auth.current; + if (caller?.attributes.conversationChannel !== "linq") return undefined; + const parsed = linqReplyTargetSchema.safeParse({ + conversationId: caller.attributes.conversationId, + messageId: caller.attributes.linqMessageId, + }); + return parsed.success ? parsed.data : undefined; +} diff --git a/agent/lib/schedules/identity.ts b/agent/lib/schedules/identity.ts index c85a4d18..377e10b4 100644 --- a/agent/lib/schedules/identity.ts +++ b/agent/lib/schedules/identity.ts @@ -2,6 +2,8 @@ import type { SessionContext } from "eve/context"; import { z } from "zod"; const scheduledReportIdentitySchema = z.object({ + linqReplyAnchorMessageId: z.string().min(1).optional(), + scheduleId: z.uuid(), scheduledReportLeaseToken: z.uuid(), scheduledReportSequence: z.coerce.number().int().positive(), scheduledRunId: z.uuid(), @@ -37,6 +39,8 @@ export function scheduledReportIdentity( const identity = scheduledReportIdentitySchema.safeParse(caller.attributes); return identity.success ? { + replyAnchorMessageId: identity.data.linqReplyAnchorMessageId, + scheduleId: identity.data.scheduleId, leaseToken: identity.data.scheduledReportLeaseToken, runId: identity.data.scheduledRunId, sequence: identity.data.scheduledReportSequence, diff --git a/agent/lib/schedules/report.ts b/agent/lib/schedules/report.ts index b82a144a..ad058c42 100644 --- a/agent/lib/schedules/report.ts +++ b/agent/lib/schedules/report.ts @@ -7,6 +7,10 @@ import { } from "@/db/services/scheduled-agent-jobs"; import linq from "../../channels/linq"; +type ClaimedScheduledReport = NonNullable< + Awaited> +>; + export async function dispatchScheduledReport( delivery: { readonly attachSession?: AttachSessionFn; @@ -23,21 +27,7 @@ export async function dispatchScheduledReport( runId: claimed.run.id, runStatus: claimed.run.status, }); - const reportAttributes = { - conversationChannel: claimed.job.conversationChannel, - conversationId: claimed.job.conversationId, - scheduleId: claimed.job.id, - scheduledReportLeaseToken: leaseToken, - scheduledReportSequence: String(claimed.run.reportSequence), - scheduledRunId: claimed.run.id, - workspaceId: claimed.job.workspaceId, - }; - const attributes = claimed.run.workerSessionId - ? { - ...reportAttributes, - scheduledRunSessionId: claimed.run.workerSessionId, - } - : reportAttributes; + const attributes = scheduledReportAttributes(claimed, leaseToken); const options = { auth: { attributes, @@ -95,14 +85,16 @@ export async function dispatchScheduledReport( } } -function scheduledReportPrompt( - claimed: NonNullable>> -) { +function scheduledReportPrompt(claimed: ClaimedScheduledReport) { + const replyContext = claimed.job.replyAnchorMessageId + ? `Reply handle: {"kind":"automation","id":"${claimed.job.id}"}. Pass this exact value as send_message.replyTo when a quoted reply to the original request would help reconnect the update. Omit replyTo when a new top-level message reads better.` + : "No reply handle is available for this automation. Omit send_message.replyTo."; if (claimed.run.pendingInputRequests) { return [ "A background scheduled run is waiting for the user before it can continue.", `Original task: ${claimed.job.prompt}`, `Scheduled for: ${claimed.run.scheduledFor.toISOString()}`, + replyContext, `Internal run ID: ${claimed.run.id}`, `Pending request: ${JSON.stringify(claimed.run.pendingInputRequests)}`, "First check whether the existing conversation clearly answers the request. If it does, call schedules-answer now. Otherwise ask the user clearly, keeping the internal run ID out of the user-visible message so schedules-answer can resume this run after they reply.", @@ -115,6 +107,35 @@ function scheduledReportPrompt( "A background scheduled run has completed.", `Original task: ${claimed.job.prompt}`, `Scheduled for: ${claimed.run.scheduledFor.toISOString()}`, + replyContext, `Worker outcome: ${JSON.stringify(claimed.run.outcome)}`, ].join("\n\n"); } + +function scheduledReportAttributes( + claimed: ClaimedScheduledReport, + leaseToken: string +) { + const attributes = new Map([ + ["conversationChannel", claimed.job.conversationChannel], + ["conversationId", claimed.job.conversationId], + ["scheduleId", claimed.job.id], + ["scheduledReportLeaseToken", leaseToken], + ["scheduledReportSequence", String(claimed.run.reportSequence)], + ["scheduledRunId", claimed.run.id], + ["workspaceId", claimed.job.workspaceId], + ]); + if ( + claimed.job.conversationChannel === "linq" && + claimed.job.replyAnchorMessageId + ) { + attributes.set( + "linqReplyAnchorMessageId", + claimed.job.replyAnchorMessageId + ); + } + if (claimed.run.workerSessionId) { + attributes.set("scheduledRunSessionId", claimed.run.workerSessionId); + } + return Object.fromEntries(attributes); +} diff --git a/agent/lib/schedules/tools.ts b/agent/lib/schedules/tools.ts index 45dc19a9..f8dcf77d 100644 --- a/agent/lib/schedules/tools.ts +++ b/agent/lib/schedules/tools.ts @@ -24,6 +24,13 @@ export function scheduleOwner(context: ToolContext) { }; } +export function scheduleReplyAnchor(context: ToolContext) { + const auth = context.session.auth.current; + if (auth?.attributes.conversationChannel !== "linq") return undefined; + const messageId = z.string().min(1).safeParse(auth.attributes.linqMessageId); + return messageId.success ? messageId.data : undefined; +} + export function scheduleSummary( job: Awaited> ) { diff --git a/agent/lib/send-message.ts b/agent/lib/send-message.ts index f441684b..4a9842d6 100644 --- a/agent/lib/send-message.ts +++ b/agent/lib/send-message.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +const replyReferenceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("current") }), + z.strictObject({ id: z.string().min(1), kind: z.literal("task") }), + z.strictObject({ id: z.uuid(), kind: z.literal("automation") }), +]); + +export type ReplyReference = z.infer; + const attachmentSchema = z.object({ kind: z.enum(["image", "video", "audio", "file"]), mimeType: z.string().min(1).max(200).optional(), @@ -16,23 +24,43 @@ const nativeLinkSchema = z message: "Native links must use HTTPS.", }); -const messageOutputSchema = z - .strictObject({ - attachments: z.array(attachmentSchema).min(1).max(4).optional(), - kind: z.literal("message"), - text: z.string().trim().min(1).max(20_000).optional(), +const messageOutputFields = { + attachments: z.array(attachmentSchema).min(1).max(4).optional(), + kind: z.literal("message"), + text: z.string().trim().min(1).max(20_000).optional(), +}; +const messageContentSchema = z.strictObject(messageOutputFields); +type MessageContent = z.infer; + +function requireMessageContent( + message: MessageContent, + context: z.RefinementCtx +) { + if (!message.text && !message.attachments) { + context.addIssue({ + code: "custom", + message: "A message must include text or at least one attachment.", + }); + } +} + +const messageOutputSchema = messageContentSchema + .extend({ + replyTo: replyReferenceSchema.optional(), }) .superRefine((message, context) => { - if (!message.text && !message.attachments) { + requireMessageContent(message, context); + if (message.replyTo && message.attachments) { context.addIssue({ code: "custom", - message: "A message must include text or at least one attachment.", + message: "Replies currently support plain text only.", }); } }); const linkOutputSchema = z.strictObject({ kind: z.literal("link"), + replyTo: replyReferenceSchema.optional(), url: nativeLinkSchema, }); diff --git a/agent/tools/messaging.ts b/agent/tools/messaging.ts index ec60090c..7c8c22d3 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -6,23 +6,27 @@ import { } from "../lib/react-to-message"; import { sendMessageOutputSchema } from "../lib/send-message"; +function defineSendMessage() { + return defineTool({ + description: + "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined. Text is delivered exactly as written, so write it like a brief natural text message and do not use Markdown. Set replyTo only when a native quoted reply helps reconnect the message to its subject: current targets the current user message, task accepts a task ID from Eve's Task state, and automation accepts the automation ID supplied by a scheduled report. Prefer task or automation replies for delayed results when intervening conversation could make the subject unclear. Use current for an explicit reply request or ambiguity among multiple unaddressed messages. Do not use current merely because you are answering the latest message or continuing an ordinary exchange; omit replyTo when the message reads naturally in chronological order. Use only handles present in the current context. Replies with attachments are unsupported. Choose kind link with a URL to send a standalone native preview. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", + inputSchema: sendMessageOutputSchema, + execute(message) { + return message; + }, + toModelOutput() { + return toolOutput.text( + "The message was submitted to the active channel. Do not repeat it in assistant text." + ); + }, + }); +} + export default defineDynamic({ events: { "turn.started": (_event, context) => { const isLinq = context.channel.kind === "channel:linq"; - const send_message = defineTool({ - description: - "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined. Text is delivered exactly as written, so write it the way it should appear to the user and do not use Markdown. Choose kind link with a URL to send a standalone link, rendered as a native Linq preview where supported. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", - inputSchema: sendMessageOutputSchema, - execute(message) { - return message; - }, - toModelOutput() { - return toolOutput.text( - "The message was submitted to the active channel. Do not repeat it in assistant text." - ); - }, - }); + const send_message = defineSendMessage(); const react_to_message = defineTool({ description: isLinq @@ -41,12 +45,15 @@ export default defineDynamic({ }, }); - const sendOnly = { send_message }; const interactive = { react_to_message, send_message }; - return resolveModeValue(context, { + type MessagingTools = + | typeof interactive + | { send_message: typeof send_message }; + + return resolveModeValue(context, { interactive, - "scheduled-report": sendOnly, + "scheduled-report": { send_message }, }); }, }, diff --git a/agent/tools/schedules.ts b/agent/tools/schedules.ts index e1ffd657..1f79bbf6 100644 --- a/agent/tools/schedules.ts +++ b/agent/tools/schedules.ts @@ -6,6 +6,7 @@ import { postScheduledRunRoute } from "@/agent/lib/schedules/request"; import { scheduleListSummary, scheduleOwner, + scheduleReplyAnchor, scheduleSummary, } from "@/agent/lib/schedules/tools"; import { scheduleTimingSchema } from "@/agent/lib/schedules/timing"; @@ -32,6 +33,7 @@ export const createSchedule = defineTool({ ...owner.conversation, missedRunPolicy: input.missedRunPolicy, prompt: input.prompt, + replyAnchorMessageId: scheduleReplyAnchor(context), timing: input.timing, }) ); diff --git a/db/migrations/0011_jazzy_zarda.sql b/db/migrations/0011_jazzy_zarda.sql new file mode 100644 index 00000000..70aa171b --- /dev/null +++ b/db/migrations/0011_jazzy_zarda.sql @@ -0,0 +1 @@ +ALTER TABLE "scheduled_agent_jobs" ADD COLUMN "reply_anchor_message_id" text; \ No newline at end of file diff --git a/db/migrations/meta/0011_snapshot.json b/db/migrations/meta/0011_snapshot.json new file mode 100644 index 00000000..2a0307ab --- /dev/null +++ b/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1943 @@ +{ + "id": "50884ede-d209-4c57-993d-3cfa06e20493", + "prevId": "6a5f5899-87c7-4f9b-8c6b-d33aef5f9198", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_issuer_accountId_uidx": { + "name": "account_issuer_accountId_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "phoneNumber": { + "name": "phoneNumber", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phoneNumberVerified": { + "name": "phoneNumberVerified", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_phoneNumber_unique": { + "name": "user_phoneNumber_unique", + "nullsNotDistinct": false, + "columns": ["phoneNumber"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_image_artifacts": { + "name": "browser_image_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_session_id": { + "name": "root_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_session_id": { + "name": "browser_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_pathname": { + "name": "storage_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_image_artifacts_workspace_idempotency_uidx": { + "name": "browser_image_artifacts_workspace_idempotency_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_image_artifacts_workspace_created_idx": { + "name": "browser_image_artifacts_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_image_artifacts_membership_fkey": { + "name": "browser_image_artifacts_membership_fkey", + "tableFrom": "browser_image_artifacts", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_image_artifacts_status_check": { + "name": "browser_image_artifacts_status_check", + "value": "\"browser_image_artifacts\".\"status\" IN ('pending', 'ready')" + }, + "browser_image_artifacts_source_kind_check": { + "name": "browser_image_artifacts_source_kind_check", + "value": "\"browser_image_artifacts\".\"source_kind\" IN ('element', 'full_page', 'image_resource', 'viewport')" + }, + "browser_image_artifacts_ready_fields_check": { + "name": "browser_image_artifacts_ready_fields_check", + "value": "\"browser_image_artifacts\".\"status\" = 'pending' OR (\"browser_image_artifacts\".\"filename\" IS NOT NULL AND \"browser_image_artifacts\".\"media_type\" IS NOT NULL AND \"browser_image_artifacts\".\"byte_size\" > 0 AND \"browser_image_artifacts\".\"content_hash\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.browser_sessions": { + "name": "browser_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_sessions_workspace_idx": { + "name": "browser_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_sessions_worker_idx": { + "name": "browser_sessions_worker_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "worker_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_sessions_membership_fkey": { + "name": "browser_sessions_membership_fkey", + "tableFrom": "browser_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_domains": { + "name": "browser_trace_domains", + "schema": "", + "columns": { + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_domains_domain_idx": { + "name": "browser_trace_domains_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_domains_trace_fkey": { + "name": "browser_trace_domains_trace_fkey", + "tableFrom": "browser_trace_domains", + "tableTo": "browser_traces", + "columnsFrom": ["trace_session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "browser_trace_domains_pkey": { + "name": "browser_trace_domains_pkey", + "columns": ["trace_session_id", "domain"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_events": { + "name": "browser_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_events_trace_idx": { + "name": "browser_trace_events_trace_idx", + "columns": [ + { + "expression": "trace_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_events_trace_fkey": { + "name": "browser_trace_events_trace_fkey", + "tableFrom": "browser_trace_events", + "tableTo": "browser_traces", + "columnsFrom": ["trace_session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_traces": { + "name": "browser_traces", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task": { + "name": "task", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_message": { + "name": "result_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_traces_workspace_started_idx": { + "name": "browser_traces_workspace_started_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_traces_membership_fkey": { + "name": "browser_traces_membership_fkey", + "tableFrom": "browser_traces", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_traces_status_check": { + "name": "browser_traces_status_check", + "value": "\"browser_traces\".\"status\" IN ('running', 'success', 'failure', 'error', 'cancelled')" + }, + "browser_traces_duration_ms_check": { + "name": "browser_traces_duration_ms_check", + "value": "\"browser_traces\".\"duration_ms\" IS NULL OR \"browser_traces\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(16, 8)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_workspace_updated_idx": { + "name": "chats_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_workspace_id_fkey": { + "name": "chats_workspace_id_fkey", + "tableFrom": "chats", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chats_input_tokens_check": { + "name": "chats_input_tokens_check", + "value": "\"chats\".\"input_tokens\" >= 0" + }, + "chats_output_tokens_check": { + "name": "chats_output_tokens_check", + "value": "\"chats\".\"output_tokens\" >= 0" + }, + "chats_cost_usd_check": { + "name": "chats_cost_usd_check", + "value": "\"chats\".\"cost_usd\" IS NULL OR \"chats\".\"cost_usd\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_jobs": { + "name": "scheduled_agent_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_channel": { + "name": "conversation_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_anchor_message_id": { + "name": "reply_anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timing": { + "name": "timing", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "missed_run_policy": { + "name": "missed_run_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'run_latest'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_jobs_due_idx": { + "name": "scheduled_agent_jobs_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_jobs_owner_idx": { + "name": "scheduled_agent_jobs_owner_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_jobs_membership_fkey": { + "name": "scheduled_agent_jobs_membership_fkey", + "tableFrom": "scheduled_agent_jobs", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_jobs_conversation_channel_check": { + "name": "scheduled_agent_jobs_conversation_channel_check", + "value": "\"scheduled_agent_jobs\".\"conversation_channel\" IN ('eve', 'linq')" + }, + "scheduled_agent_jobs_conversation_id_check": { + "name": "scheduled_agent_jobs_conversation_id_check", + "value": "\"scheduled_agent_jobs\".\"conversation_id\" <> ''" + }, + "scheduled_agent_jobs_missed_run_policy_check": { + "name": "scheduled_agent_jobs_missed_run_policy_check", + "value": "\"scheduled_agent_jobs\".\"missed_run_policy\" IN ('skip', 'run_latest', 'catch_up')" + }, + "scheduled_agent_jobs_status_check": { + "name": "scheduled_agent_jobs_status_check", + "value": "\"scheduled_agent_jobs\".\"status\" IN ('active', 'paused', 'completed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_runs": { + "name": "scheduled_agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deferred_completion_turn_id": { + "name": "deferred_completion_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_input_requests": { + "name": "pending_input_requests", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_ready'" + }, + "report_sequence": { + "name": "report_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "report_lease_token": { + "name": "report_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report_lease_expires_at": { + "name": "report_lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_runs_occurrence_idx": { + "name": "scheduled_agent_runs_occurrence_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_ready_idx": { + "name": "scheduled_agent_runs_ready_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_report_idx": { + "name": "scheduled_agent_runs_report_idx", + "columns": [ + { + "expression": "report_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk": { + "name": "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk", + "tableFrom": "scheduled_agent_runs", + "tableTo": "scheduled_agent_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_runs_status_check": { + "name": "scheduled_agent_runs_status_check", + "value": "\"scheduled_agent_runs\".\"status\" IN ('queued', 'running', 'waiting_for_input', 'completed', 'dead_letter')" + }, + "scheduled_agent_runs_report_status_check": { + "name": "scheduled_agent_runs_report_status_check", + "value": "\"scheduled_agent_runs\".\"report_status\" IN ('not_ready', 'not_needed', 'pending', 'queued', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_sessions_workspace_idx": { + "name": "agent_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_membership_fkey": { + "name": "agent_sessions_membership_fkey", + "tableFrom": "agent_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_secrets": { + "name": "encrypted_secrets", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_secrets_workspace_id_fkey": { + "name": "encrypted_secrets_workspace_id_fkey", + "tableFrom": "encrypted_secrets", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_secrets_pkey": { + "name": "encrypted_secrets_pkey", + "columns": ["workspace_id", "namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "encrypted_secrets_namespace_check": { + "name": "encrypted_secrets_namespace_check", + "value": "\"encrypted_secrets\".\"namespace\" = 'vault'" + } + }, + "isRLSEnabled": false + }, + "public.vault_items": { + "name": "vault_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vault_items_workspace_updated_idx": { + "name": "vault_items_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_items_workspace_id_fkey": { + "name": "vault_items_workspace_id_fkey", + "tableFrom": "vault_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "vault_items_kind_check": { + "name": "vault_items_kind_check", + "value": "\"vault_items\".\"kind\" IN ('login', 'payment', 'address', 'contact', 'phone', 'identity', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_workspace_id_fkey": { + "name": "settings_workspace_id_fkey", + "tableFrom": "settings", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_pkey": { + "name": "settings_pkey", + "columns": ["workspace_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_key_check": { + "name": "settings_key_check", + "value": "\"settings\".\"key\" = 'gateway_model'" + } + }, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_workspace_id_fkey": { + "name": "user_profiles_workspace_id_fkey", + "tableFrom": "user_profiles", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_profiles_country_code_check": { + "name": "user_profiles_country_code_check", + "value": "\"user_profiles\".\"country_code\" IS NULL OR char_length(\"user_profiles\".\"country_code\") = 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_memberships": { + "name": "workspace_memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_memberships_workspace_id_fkey": { + "name": "workspace_memberships_workspace_id_fkey", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_memberships_pkey": { + "name": "workspace_memberships_pkey", + "columns": ["workspace_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_memberships_role_check": { + "name": "workspace_memberships_role_check", + "value": "\"workspace_memberships\".\"role\" = 'owner'" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 6d04718d..68169363 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1788393204481, "tag": "0010_rapid_cerise", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1788452649700, + "tag": "0011_jazzy_zarda", + "breakpoints": true } ] } diff --git a/db/schema/schedules.ts b/db/schema/schedules.ts index 3aeb65a0..3c6c78cd 100644 --- a/db/schema/schedules.ts +++ b/db/schema/schedules.ts @@ -25,6 +25,7 @@ export const scheduledAgentJobs = pgTable( enum: ["eve", "linq"], }).notNull(), conversationId: text("conversation_id").notNull(), + replyAnchorMessageId: text("reply_anchor_message_id"), timing: jsonb("timing").notNull(), missedRunPolicy: text("missed_run_policy", { enum: ["skip", "run_latest", "catch_up"], diff --git a/db/services/scheduled-agent-jobs.ts b/db/services/scheduled-agent-jobs.ts index fc0f0ee7..0c88d554 100644 --- a/db/services/scheduled-agent-jobs.ts +++ b/db/services/scheduled-agent-jobs.ts @@ -37,6 +37,7 @@ export interface CreateScheduledAgentJob { readonly conversationId: string; readonly missedRunPolicy: "catch_up" | "run_latest"; readonly prompt: string; + readonly replyAnchorMessageId?: string; readonly timing: ScheduleTiming; } @@ -77,6 +78,7 @@ export async function createScheduledAgentJob( missedRunPolicy: input.missedRunPolicy, nextRunAt, prompt: input.prompt, + replyAnchorMessageId: input.replyAnchorMessageId, status: "active", timing: input.timing, updatedAt: now, diff --git a/db/tests/database-migration.test.ts b/db/tests/database-migration.test.ts index d6ba800d..c6a6f970 100644 --- a/db/tests/database-migration.test.ts +++ b/db/tests/database-migration.test.ts @@ -38,6 +38,7 @@ describe("database migrations", () => { await applyMigration(database, "0008_black_sandman.sql"); await applyMigration(database, "0009_cold_power_man.sql"); await applyMigration(database, "0010_rapid_cerise.sql"); + await applyMigration(database, "0011_jazzy_zarda.sql"); const tables = await database.query<{ count: number }>( `SELECT count(*)::int AS count diff --git a/db/tests/scheduled-agent-jobs.test.ts b/db/tests/scheduled-agent-jobs.test.ts index c7cfac44..e26c0fd0 100644 --- a/db/tests/scheduled-agent-jobs.test.ts +++ b/db/tests/scheduled-agent-jobs.test.ts @@ -30,6 +30,7 @@ describe("scheduled agent jobs", () => { "0008_black_sandman.sql", "0009_cold_power_man.sql", "0010_rapid_cerise.sql", + "0011_jazzy_zarda.sql", ]) { await applyMigration(client, migration); } diff --git a/evals/agent/conversation.eval.ts b/evals/agent/conversation.eval.ts index 6c627ec4..d6f86941 100644 --- a/evals/agent/conversation.eval.ts +++ b/evals/agent/conversation.eval.ts @@ -1,6 +1,7 @@ import { defineEval, type EveEvalContext } from "eve/evals"; import { includes, satisfies } from "eve/evals/expect"; import { reactToMessageOutputSchema } from "@/agent/lib/react-to-message"; +import { sendMessageOutputSchema } from "@/agent/lib/send-message"; import { agentEvalTags, assertPlainTextDelivery, @@ -146,4 +147,192 @@ const reactionEvals = [ }), ]; -export default [...textEvals, ...reactionEvals]; +const replyEvals = [ + defineEval({ + description: "Honors an explicit request for a quoted reply", + tags: [...agentEvalTags, "conversation", "reply", "contract", "smoke"], + async test(t) { + const turn = await t.send( + "Use a quoted reply to this message and say exactly: Got it." + ); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo?.kind === "current" && + parsed.data.text === "Got it." + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + description: + "Reconnects a delayed background result to its initiating request", + tags: [...agentEvalTags, "conversation", "reply", "background"], + async test(t) { + const taskId = "task_movie_search_01"; + const turn = await t.send("Continue with the completed work.", { + clientContext: [ + "Background task reporting. This turn was triggered by completed background work after unrelated conversation occurred.", + `[Task state]\n${JSON.stringify({ + tasks: [ + { + name: "worker", + output: + "The best nearby showing is 6:00 PM XPlus at Showcase Legacy Place, and availability was confirmed.", + status: "completed", + taskId, + }, + ], + })}`, + ], + }); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo?.kind === "task" && + parsed.data.replyTo.id === taskId + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + description: + "Reconnects an automation update to the request that created it", + tags: [...agentEvalTags, "conversation", "reply", "automation"], + async test(t) { + const automationId = "00000000-0000-4000-8000-000000000003"; + const turn = await t.send("Continue with the scheduled update.", { + clientContext: [ + "A background scheduled run has completed after the conversation moved on.", + "Original task: Remind me to renew TSA PreCheck before Thursday.", + `Reply handle: ${JSON.stringify({ kind: "automation", id: automationId })}`, + "Worker outcome: The appointment is Thursday, so the user should finish the form today.", + ], + }); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo?.kind === "automation" && + parsed.data.replyTo.id === automationId + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + description: "Omits a quoted reply for an ordinary answer", + tags: [...agentEvalTags, "conversation", "reply", "smoke"], + async test(t) { + const turn = await t.send("What is 14 plus 9? Answer briefly."); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo === undefined && + parsed.data.text?.includes("23") === true + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + description: "Omits a quoted reply in an ordinary conversational follow-up", + tags: [...agentEvalTags, "conversation", "reply", "smoke"], + async test(t) { + const question = await t.send( + "Ask me in a normal text whether I want you to focus on Boston or New York." + ); + question.expectOk(); + await requireDeliveredText(t, question); + const answer = await t.send( + "Boston. Briefly confirm that you will focus there." + ); + answer.expectOk(); + answer.succeeded(); + answer.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo === undefined && + /boston/iu.test(parsed.data.text ?? "") + ); + }, + status: "completed", + }); + answer.notCalledTool("react_to_message"); + answer.maxToolCalls(1); + }, + }), + defineEval({ + description: "Omits a quoted reply after an ordinary topic switch", + tags: [...agentEvalTags, "conversation", "reply", "smoke"], + async test(t) { + const first = await t.send("What is 2 plus 2? Keep it brief."); + first.expectOk(); + await requireDeliveredText(t, first); + + const second = await t.send( + "Separate question: what is the capital of France? Keep it brief." + ); + second.expectOk(); + second.succeeded(); + second.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo === undefined && + /paris/iu.test(parsed.data.text ?? "") + ); + }, + status: "completed", + }); + second.notCalledTool("react_to_message"); + second.maxToolCalls(1); + }, + }), +]; + +export default [...textEvals, ...replyEvals, ...reactionEvals]; diff --git a/src/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts b/src/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts index 5ba4affa..f4cf866b 100644 --- a/src/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts +++ b/src/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts @@ -128,6 +128,30 @@ describe("iMessage event projection", () => { ]); }); + it("treats reply association as transport metadata in the Eve chat", () => { + const events = [ + toolResult( + "send_message", + { + kind: "message", + replyTo: { kind: "current" }, + text: "This is still a normal Eve message.", + }, + 1 + ), + ]; + + expect(sentMessages(events).get("turn-1:assistant")).toEqual([ + expect.objectContaining({ + parts: [ + expect.objectContaining({ + text: "This is still a normal Eve message.", + }), + ], + }), + ]); + }); + it("keeps consecutive sends in the same turn as separate messages", () => { const events = [ toolResult( diff --git a/tests/agent/channels/eve-message-delivery.test.ts b/tests/agent/channels/eve-message-delivery.test.ts index 3b5c0d37..46db30b4 100644 --- a/tests/agent/channels/eve-message-delivery.test.ts +++ b/tests/agent/channels/eve-message-delivery.test.ts @@ -106,6 +106,7 @@ function scheduledReportSession() { auth: { current: { attributes: { + scheduleId: "00000000-0000-4000-8000-000000000001", scheduledReportLeaseToken: "00000000-0000-4000-8000-000000000004", scheduledReportSequence: "1", scheduledRunId: "00000000-0000-4000-8000-000000000002", diff --git a/tests/agent/channels/linq-inbound-auth.test.ts b/tests/agent/channels/linq-inbound-auth.test.ts index 43adf8d2..a62b3676 100644 --- a/tests/agent/channels/linq-inbound-auth.test.ts +++ b/tests/agent/channels/linq-inbound-auth.test.ts @@ -108,6 +108,7 @@ describe("Linq inbound authentication", () => { expect(result?.auth?.attributes).toMatchObject({ conversationChannel: "linq", conversationId: "linq:dm:chat-1", + linqMessageId: "message-1", phoneNumber: "+15550100011", }); expect(result?.auth?.attributes.workspaceId).toMatch( diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index e7571d47..cfd276bb 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -26,6 +26,8 @@ type NativeMessageOptions = Parameters< LinqAPIV3["chats"]["messages"]["send"] >[2]; +const rawMessage = (id: string) => ({ id }); + const linqChannelCapture = vi.hoisted(() => ({ // SAFETY: This mutable test capture stores only API keys from the typed SDK constructor mock. clientApiKeys: [] as string[], @@ -48,9 +50,9 @@ const linqChannelCapture = vi.hoisted(() => ({ threadId: string, message: AdapterPostableMessage, options?: LinqSendOptions - ) => Promise + ) => Promise<{ id: string }> >() - .mockResolvedValue(undefined), + .mockResolvedValue({ id: "scheduled-message-1" }), resolveApiKey: vi .fn<() => Promise>() .mockResolvedValue("linq-test-api-key"), @@ -60,9 +62,9 @@ const linqChannelCapture = vi.hoisted(() => ({ chatId: string, body: NativeMessageBody, options?: NativeMessageOptions - ) => Promise + ) => Promise<{ message: { id: string } }> >() - .mockResolvedValue(undefined), + .mockResolvedValue({ message: { id: "native-message-1" } }), })); const scheduleDeliveryCapture = vi.hoisted(() => ({ finalize: vi.fn(), @@ -195,6 +197,48 @@ describe("Linq message delivery", () => { expect(post).toHaveBeenCalledExactlyOnceWith({ raw: message }); }); + it("sends a native reply to the current inbound message", async () => { + const { context, post } = handlerContext(); + + await handleActionResult( + sendMessageResult({ + kind: "message", + replyTo: { kind: "current" }, + text: "Yes, that one.", + }), + context, + sessionContext("test", undefined, "message-to-reply-to") + ); + + expect(linqChannelCapture.sendNativeMessage).toHaveBeenCalledWith( + "chat-1", + { + message: { + parts: [{ type: "text", value: "Yes, that one." }], + reply_to: { message_id: "message-to-reply-to" }, + }, + }, + undefined + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("falls back to a normal message when a reply handle is unavailable", async () => { + const { context, post } = handlerContext(null); + + await handleActionResult( + sendMessageResult({ + kind: "message", + replyTo: { kind: "current" }, + text: "Yes, that one.", + }), + context, + sessionContext("test", undefined, null) + ); + + expect(post).toHaveBeenCalledExactlyOnceWith({ raw: "Yes, that one." }); + }); + it("finalizes a scheduled result after send_message posts it", async () => { const { context } = handlerContext(); @@ -219,6 +263,86 @@ describe("Linq message delivery", () => { ); }); + it("replies scheduled results to their initiating message", async () => { + const { context } = handlerContext(); + + await handleActionResult( + sendMessageResult({ + kind: "message", + replyTo: { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, + text: "Time to renew it.", + }), + context, + sessionContext("scheduled-result", "original-message") + ); + + expect(linqChannelCapture.sendNativeMessage).toHaveBeenCalledWith( + "chat-1", + { + message: { + idempotency_key: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + parts: [{ type: "text", value: "Time to renew it." }], + reply_to: { message_id: "original-message" }, + }, + }, + undefined + ); + }); + + it("keeps scheduled native link replies idempotent", async () => { + const { context } = handlerContext(); + + await handleActionResult( + sendMessageResult({ + kind: "link", + replyTo: { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, + url: "https://example.com/renew", + }), + context, + sessionContext("scheduled-result", "original-message") + ); + + expect(linqChannelCapture.sendNativeMessage).toHaveBeenCalledWith( + "chat-1", + { + message: { + idempotency_key: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + parts: [{ type: "link", value: "https://example.com/renew" }], + reply_to: { message_id: "original-message" }, + }, + }, + undefined + ); + }); + + it("falls back without changing an automation anchor when the old target is unavailable", async () => { + linqChannelCapture.sendNativeMessage.mockRejectedValueOnce({ status: 404 }); + const { context } = handlerContext(); + + await handleActionResult( + sendMessageResult({ + kind: "message", + replyTo: { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, + text: "Time to renew it.", + }), + context, + sessionContext("scheduled-result", "expired-message") + ); + + expect(linqChannelCapture.postMessage).toHaveBeenCalledOnce(); + }); + it("uses the same Linq idempotency key when a report turn is retried", async () => { const { context } = handlerContext(); const event = sendMessageResult({ @@ -299,6 +423,41 @@ describe("Linq message delivery", () => { ).toBe(false); }); + it("accepts typed reply handles for text and native links", () => { + expect( + sendMessageOutputSchema.safeParse({ + kind: "message", + replyTo: { kind: "current" }, + text: "This one.", + }).success + ).toBe(true); + expect( + sendMessageOutputSchema.safeParse({ + kind: "message", + replyTo: { id: "task-1", kind: "task" }, + text: "This one.", + }).success + ).toBe(true); + expect( + sendMessageOutputSchema.safeParse({ + kind: "link", + replyTo: { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, + url: "https://example.com", + }).success + ).toBe(true); + expect( + sendMessageOutputSchema.safeParse({ + attachments: [{ kind: "image", url: "https://example.com/image.png" }], + kind: "message", + replyTo: { kind: "current" }, + text: "This one.", + }).success + ).toBe(false); + }); + it("discriminates native links from message content", () => { expect( sendMessageOutputSchema.safeParse({ @@ -349,7 +508,7 @@ describe("Linq message delivery", () => { }); it("posts a proactive message without a current inbound message", async () => { - const { context, post } = handlerContext(undefined); + const { context, post } = handlerContext(null); await handleActionResult( sendMessageResult({ @@ -624,9 +783,9 @@ function reactToMessageResult( }; } -function handlerContext(currentMessageId: string | undefined = "message-1") { - const post = vi.fn<(message: LinqTestMessage) => Promise>(); - post.mockResolvedValue(); +function handlerContext(currentMessageId: string | null = "message-1") { + const post = vi.fn<(message: LinqTestMessage) => Promise<{ id: string }>>(); + post.mockResolvedValue(rawMessage("message-2")); const addReaction = vi .fn<(threadId: string, messageId: string, emoji: string) => Promise>() .mockResolvedValue(undefined); @@ -674,17 +833,34 @@ function handlerEventContext(value: unknown): ActionHandlerParameters[1] { return value as ActionHandlerParameters[1]; } -function sessionContext(authenticator = "test") { +function sessionContext( + authenticator = "test", + replyAnchorMessageId?: string, + currentMessageId: string | null = "message-1" +) { const attributes: Record = authenticator === "scheduled-result" ? { + conversationChannel: "linq", + conversationId: "linq:dm:chat-1", + scheduleId: "00000000-0000-4000-8000-000000000003", scheduledReportLeaseToken: "00000000-0000-4000-8000-000000000004", scheduledReportSequence: "1", scheduledRunId: "00000000-0000-4000-8000-000000000002", scheduledRunSessionId: "scheduled-run-session", workspaceId: "workspace-1", } - : { workspaceId: "workspace-1" }; + : { + conversationChannel: "linq", + conversationId: "linq:dm:chat-1", + workspaceId: "workspace-1", + }; + if (authenticator !== "scheduled-result" && currentMessageId) { + attributes.linqMessageId = currentMessageId; + } + if (replyAnchorMessageId) { + attributes.linqReplyAnchorMessageId = replyAnchorMessageId; + } return { async getSandbox() { throw new Error("Sandbox access is outside this focused test."); diff --git a/tests/agent/reply-targets.test.ts b/tests/agent/reply-targets.test.ts new file mode 100644 index 00000000..e8ec9ddc --- /dev/null +++ b/tests/agent/reply-targets.test.ts @@ -0,0 +1,183 @@ +import type { SessionAuth } from "eve/context"; +import type { HookContext } from "eve/hooks"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const stateControls = vi.hoisted(() => ({ + // SAFETY: The array is populated only with zero-argument reset callbacks created by this mock. + reset: [] as (() => void)[], +})); + +vi.mock("eve/context", () => ({ + defineState(_name: string, initial: () => T) { + let value = initial(); + stateControls.reset.push(() => { + value = initial(); + }); + return { + get: () => value, + update(update: (current: T) => T) { + value = update(value); + }, + }; + }, +})); + +import backgroundReplyTargetHook from "@/agent/hooks/background-reply-target"; +import { + registerBackgroundReplyTarget, + resolveLinqReplyTarget, +} from "@/agent/lib/reply-targets"; + +beforeEach(() => { + for (const reset of stateControls.reset) reset(); +}); + +describe("reply targets", () => { + it("resolves the current Linq message without exposing its provider ID", () => { + expect( + resolveLinqReplyTarget({ kind: "current" }, linqAuth("message-1")) + ).toEqual({ + conversationId: "linq:dm:chat-1", + messageId: "message-1", + }); + }); + + it("keeps a background task attached to its initiating message", () => { + registerBackgroundReplyTarget("task-1", linqAuth("origin-message")); + + expect( + resolveLinqReplyTarget( + { id: "task-1", kind: "task" }, + backgroundWakeAuth() + ) + ).toEqual({ + conversationId: "linq:dm:chat-1", + messageId: "origin-message", + }); + }); + + it("does not resolve a task handle in another conversation", () => { + registerBackgroundReplyTarget("task-1", linqAuth("origin-message")); + + expect( + resolveLinqReplyTarget( + { id: "task-1", kind: "task" }, + linqAuth("later-message", "linq:dm:chat-2") + ) + ).toBeUndefined(); + }); + + it("registers background subagent receipts through the public hook", async () => { + const handler = backgroundReplyTargetHook.events?.["subagent.completed"]; + await handler?.( + { + data: { + backgroundTask: { status: "working", taskId: "task-from-hook" }, + callId: "call-1", + output: "Delegated", + subagentName: "worker", + }, + meta: { at: "2026-09-03T12:00:00.000Z", id: "event-1" }, + type: "subagent.completed", + }, + hookContext(linqAuth("hook-origin")) + ); + + expect( + resolveLinqReplyTarget( + { id: "task-from-hook", kind: "task" }, + linqAuth("later-message") + ) + ).toMatchObject({ messageId: "hook-origin" }); + }); + + it("resolves only the automation handle supplied by the reporting turn", () => { + const auth = scheduledReportAuth("original-message"); + + expect( + resolveLinqReplyTarget( + { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, + auth + ) + ).toEqual({ + conversationId: "linq:dm:chat-1", + messageId: "original-message", + }); + expect( + resolveLinqReplyTarget( + { + id: "00000000-0000-4000-8000-000000000099", + kind: "automation", + }, + auth + ) + ).toBeUndefined(); + }); +}); + +function linqAuth( + messageId: string, + conversationId = "linq:dm:chat-1" +): SessionAuth { + return { + current: { + attributes: { + conversationChannel: "linq", + conversationId, + linqMessageId: messageId, + }, + authenticator: "linq", + principalId: "user-1", + principalType: "user", + }, + initiator: null, + }; +} + +function scheduledReportAuth(messageId: string): SessionAuth { + return { + current: { + attributes: { + conversationChannel: "linq", + conversationId: "linq:dm:chat-1", + linqReplyAnchorMessageId: messageId, + scheduleId: "00000000-0000-4000-8000-000000000003", + scheduledReportLeaseToken: "00000000-0000-4000-8000-000000000004", + scheduledReportSequence: "1", + scheduledRunId: "00000000-0000-4000-8000-000000000002", + }, + authenticator: "scheduled-result", + principalId: "user-1", + principalType: "user", + }, + initiator: null, + }; +} + +function backgroundWakeAuth(): SessionAuth { + return { + current: null, + initiator: linqAuth("origin-message").current, + }; +} + +function hookContext(auth: SessionAuth): HookContext { + return { + agent: { name: "main" }, + channel: { kind: "channel:linq" }, + async getSandbox() { + throw new Error("Sandbox access is outside this focused test."); + }, + getSkill() { + throw new Error("Skill access is outside this focused test."); + }, + session: { + auth, + id: "session-1", + turn: { id: "turn-1", sequence: 0 }, + }, + }; +} diff --git a/tests/agent/schedules/dynamic.test.ts b/tests/agent/schedules/dynamic.test.ts index e80ca458..aa35d6d0 100644 --- a/tests/agent/schedules/dynamic.test.ts +++ b/tests/agent/schedules/dynamic.test.ts @@ -173,6 +173,7 @@ describe("scheduled report delivery", () => { it("routes Linq reports to the stored conversation", async () => { const report = scheduledReport(); + report.job.replyAnchorMessageId = "original-message"; services.claimReports.mockResolvedValue(report); const send = vi .fn["send"]>() @@ -188,9 +189,18 @@ describe("scheduled report delivery", () => { }); expect(attachSession).not.toHaveBeenCalled(); expect(send.mock.calls[0]?.[1]).toMatchObject({ - auth: { authenticator: "scheduled-result" }, + auth: { + attributes: { + linqReplyAnchorMessageId: "original-message", + scheduleId: report.job.id, + }, + authenticator: "scheduled-result", + }, turnPolicy: "queue", }); + expect(send.mock.calls[0]?.[0]).toContain( + `Reply handle: {"kind":"automation","id":"${report.job.id}"}` + ); }); it("routes Eve reports to the stored debug session", async () => { @@ -300,6 +310,7 @@ function scheduledClaim(): Awaited< missedRunPolicy: "run_latest", nextRunAt: new Date("2026-09-03T13:00:00.000Z"), prompt: "Watch the price.", + replyAnchorMessageId: null, revision: 0, status: "active", timing: { diff --git a/tests/agent/tools/schedules.test.ts b/tests/agent/tools/schedules.test.ts index 70a49f85..9d901f3d 100644 --- a/tests/agent/tools/schedules.test.ts +++ b/tests/agent/tools/schedules.test.ts @@ -151,6 +151,7 @@ describe("schedule tools", () => { conversationId: "linq:dm:chat-1", missedRunPolicy: "run_latest", prompt: "Send the morning summary.", + replyAnchorMessageId: "message-1", timing: { frequency: "daily", kind: "calendar", @@ -243,6 +244,33 @@ describe("schedule tools", () => { "react_to_message", "send_message", ]); + const reportSend = + reportMessaging && !("execute" in reportMessaging) + ? reportMessaging.send_message + : undefined; + const interactiveSend = + interactiveMessaging && !("execute" in interactiveMessaging) + ? interactiveMessaging.send_message + : undefined; + const debugSend = + debugMessaging && !("execute" in debugMessaging) + ? debugMessaging.send_message + : undefined; + if ( + !(reportSend?.inputSchema instanceof z.ZodType) || + !(interactiveSend?.inputSchema instanceof z.ZodType) || + !(debugSend?.inputSchema instanceof z.ZodType) + ) { + throw new Error("Expected authored send_message schemas."); + } + const reply = { + kind: "message", + replyTo: { kind: "current" as const }, + text: "This one.", + }; + expect(interactiveSend.inputSchema.safeParse(reply).success).toBe(true); + expect(debugSend.inputSchema.safeParse(reply).success).toBe(true); + expect(reportSend.inputSchema.safeParse(reply).success).toBe(true); }); it("owns web schedules by their Eve session", async () => { @@ -340,6 +368,7 @@ function toolContext( attributes: { conversationChannel, conversationId: "linq:dm:chat-1", + linqMessageId: "message-1", linqThreadId: "linq:dm:chat-1", workspaceId: "workspace-1", }, @@ -369,6 +398,7 @@ function scheduledReportToolContext() { ...current, attributes: { ...current.attributes, + scheduleId: "00000000-0000-4000-8000-000000000001", scheduledReportLeaseToken: "00000000-0000-4000-8000-000000000004", scheduledReportSequence: "1", scheduledRunId: "00000000-0000-4000-8000-000000000002", @@ -406,6 +436,7 @@ function scheduledJob( missedRunPolicy: "run_latest", nextRunAt: new Date("2026-09-02T13:00:00.000Z"), prompt: "Send the morning summary.", + replyAnchorMessageId: null, revision: 0, status: "active", timing: { From 828df8b59f2ed4b7611a76a55f49be196af975aa Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:24:09 -0400 Subject: [PATCH 2/4] Make reply threading the default --- agent/lib/schedules/report.ts | 2 +- agent/tools/messaging.ts | 2 +- evals/agent/conversation.eval.ts | 83 ++++++++++++++++----------- tests/agent/schedules/dynamic.test.ts | 3 + 4 files changed, 55 insertions(+), 35 deletions(-) diff --git a/agent/lib/schedules/report.ts b/agent/lib/schedules/report.ts index 860a6d84..e050a0bb 100644 --- a/agent/lib/schedules/report.ts +++ b/agent/lib/schedules/report.ts @@ -87,7 +87,7 @@ export async function dispatchScheduledReport( function scheduledReportPrompt(claimed: ClaimedScheduledReport) { const replyContext = claimed.job.replyAnchorMessageId - ? `Reply handle: {"kind":"automation","id":"${claimed.job.id}"}. Pass this exact value as send_message.replyTo when a quoted reply to the original request would help reconnect the update. Omit replyTo when a new top-level message reads better.` + ? `Reply handle: {"kind":"automation","id":"${claimed.job.id}"}. Pass this exact value as send_message.replyTo for every user-visible message about this scheduled task. Omit replyTo only when the message is genuinely unrelated to the scheduled task.` : "No reply handle is available for this automation. Omit send_message.replyTo."; if (claimed.run.pendingInputRequests) { return [ diff --git a/agent/tools/messaging.ts b/agent/tools/messaging.ts index ae398351..25a6635f 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -9,7 +9,7 @@ import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; function defineSendMessage() { return defineTool({ description: - "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined. Text is delivered exactly as written, so write it like a brief natural text message and do not use Markdown. Set replyTo only when a native quoted reply helps reconnect the message to its subject: current targets the current user message, task accepts a task ID from Eve's Task state, and automation accepts the automation ID supplied by a scheduled report. Prefer task or automation replies for delayed results when intervening conversation could make the subject unclear. Use current for an explicit reply request or ambiguity among multiple unaddressed messages. Do not use current merely because you are answering the latest message or continuing an ordinary exchange; omit replyTo when the message reads naturally in chronological order. Use only handles present in the current context. Replies with attachments are unsupported. Choose kind link with a URL to send a standalone native preview. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", + "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined. Text is delivered exactly as written, so write it like a brief natural text message and do not use Markdown. Put nearly every response in a native quoted thread by setting replyTo: use current for an ordinary answer, clarification, status update, or follow-up prompted by the current user message, including when the user changes topics; use task with a task ID from Eve's Task state for delayed background work; and use automation with the automation ID supplied by a scheduled report. Omit replyTo only when the message is genuinely standalone and does not answer any particular user message, such as an unsolicited announcement or proactive notice, or when no applicable handle is available. Use only handles present in the current context. Replies with attachments are unsupported. Choose kind link with a URL to send a standalone native preview. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", inputSchema: sendMessageOutputSchema, execute(message) { return message; diff --git a/evals/agent/conversation.eval.ts b/evals/agent/conversation.eval.ts index d8972e5b..c1879543 100644 --- a/evals/agent/conversation.eval.ts +++ b/evals/agent/conversation.eval.ts @@ -148,32 +148,6 @@ const reactionEvals = [ ]; const replyEvals = [ - defineEval({ - description: "Honors an explicit request for a quoted reply", - tags: [...agentEvalTags, "conversation", "reply", "contract", "smoke"], - async test(t) { - const turn = await t.send( - "Use a quoted reply to this message and say exactly: Got it." - ); - turn.expectOk(); - turn.succeeded(); - turn.calledTool("send_message", { - count: 1, - input: (input) => { - const parsed = sendMessageOutputSchema.safeParse(input); - return ( - parsed.success && - parsed.data.kind === "message" && - parsed.data.replyTo?.kind === "current" && - parsed.data.text === "Got it." - ); - }, - status: "completed", - }); - turn.notCalledTool("react_to_message"); - turn.maxToolCalls(1); - }, - }), defineEval({ description: "Reconnects a delayed background result to its initiating request", @@ -225,7 +199,7 @@ const replyEvals = [ clientContext: [ "A background scheduled run has completed after the conversation moved on.", "Original task: Remind me to renew TSA PreCheck before Thursday.", - `Reply handle: ${JSON.stringify({ kind: "automation", id: automationId })}`, + `Reply handle: ${JSON.stringify({ kind: "automation", id: automationId })}. Pass this exact value as send_message.replyTo for every user-visible message about this scheduled task. Omit replyTo only when the message is genuinely unrelated to the scheduled task.`, "Worker outcome: The appointment is Thursday, so the user should finish the form today.", ], }); @@ -249,7 +223,7 @@ const replyEvals = [ }, }), defineEval({ - description: "Omits a quoted reply for an ordinary answer", + description: "Replies to the current message for an ordinary answer", tags: [...agentEvalTags, "conversation", "reply", "smoke"], async test(t) { const turn = await t.send("What is 14 plus 9? Answer briefly."); @@ -262,7 +236,7 @@ const replyEvals = [ return ( parsed.success && parsed.data.kind === "message" && - parsed.data.replyTo === undefined && + parsed.data.replyTo?.kind === "current" && parsed.data.text?.includes("23") === true ); }, @@ -273,13 +247,26 @@ const replyEvals = [ }, }), defineEval({ - description: "Omits a quoted reply in an ordinary conversational follow-up", + description: + "Replies to each message in an ordinary conversational exchange", tags: [...agentEvalTags, "conversation", "reply", "smoke"], async test(t) { const question = await t.send( "Ask me in a normal text whether I want you to focus on Boston or New York." ); question.expectOk(); + question.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo?.kind === "current" + ); + }, + status: "completed", + }); await requireDeliveredText(t, question); const answer = await t.send( "Boston. Briefly confirm that you will focus there." @@ -293,7 +280,7 @@ const replyEvals = [ return ( parsed.success && parsed.data.kind === "message" && - parsed.data.replyTo === undefined && + parsed.data.replyTo?.kind === "current" && /boston/iu.test(parsed.data.text ?? "") ); }, @@ -304,7 +291,7 @@ const replyEvals = [ }, }), defineEval({ - description: "Omits a quoted reply after an ordinary topic switch", + description: "Replies to the current message after a topic switch", tags: [...agentEvalTags, "conversation", "reply", "smoke"], async test(t) { const first = await t.send("What is 2 plus 2? Keep it brief."); @@ -323,7 +310,7 @@ const replyEvals = [ return ( parsed.success && parsed.data.kind === "message" && - parsed.data.replyTo === undefined && + parsed.data.replyTo?.kind === "current" && /paris/iu.test(parsed.data.text ?? "") ); }, @@ -333,6 +320,36 @@ const replyEvals = [ second.maxToolCalls(1); }, }), + defineEval({ + description: "Keeps a genuinely standalone announcement out of a thread", + tags: [...agentEvalTags, "conversation", "reply", "announcement"], + async test(t) { + const turn = await t.send("Continue with the system announcement.", { + clientContext: [ + "This is an internally initiated announcement turn, not a response to the trigger text or any earlier user request.", + "Announcement to deliver: OpenInstinct will be unavailable for scheduled maintenance tonight at 11 PM.", + "Send the announcement as a brief user-visible message.", + ], + }); + turn.expectOk(); + turn.succeeded(); + turn.calledTool("send_message", { + count: 1, + input: (input) => { + const parsed = sendMessageOutputSchema.safeParse(input); + return ( + parsed.success && + parsed.data.kind === "message" && + parsed.data.replyTo === undefined && + /maintenance/iu.test(parsed.data.text ?? "") + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), ]; export default [...textEvals, ...replyEvals, ...reactionEvals]; diff --git a/tests/agent/schedules/dynamic.test.ts b/tests/agent/schedules/dynamic.test.ts index 191f5151..4c04ba92 100644 --- a/tests/agent/schedules/dynamic.test.ts +++ b/tests/agent/schedules/dynamic.test.ts @@ -201,6 +201,9 @@ describe("scheduled report delivery", () => { expect(send.mock.calls[0]?.[0]).toContain( `Reply handle: {"kind":"automation","id":"${report.job.id}"}` ); + expect(send.mock.calls[0]?.[0]).toContain( + "Pass this exact value as send_message.replyTo for every user-visible message about this scheduled task." + ); }); it("routes Eve reports to the stored debug session", async () => { From 23aae104b57458de527b6a299d5a726fed4f47fe Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:28:04 -0400 Subject: [PATCH 3/4] Reconcile merged reply migration --- db/migrations/0012_harsh_domino.sql | 4 +++- db/tests/database-migration.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/db/migrations/0012_harsh_domino.sql b/db/migrations/0012_harsh_domino.sql index 70aa171b..dc7b4e7a 100644 --- a/db/migrations/0012_harsh_domino.sql +++ b/db/migrations/0012_harsh_domino.sql @@ -1 +1,3 @@ -ALTER TABLE "scheduled_agent_jobs" ADD COLUMN "reply_anchor_message_id" text; \ No newline at end of file +ALTER TABLE "chats" ADD COLUMN IF NOT EXISTS "channel" text; +--> statement-breakpoint +ALTER TABLE "scheduled_agent_jobs" ADD COLUMN IF NOT EXISTS "reply_anchor_message_id" text; diff --git a/db/tests/database-migration.test.ts b/db/tests/database-migration.test.ts index cb3bd7ed..29653c86 100644 --- a/db/tests/database-migration.test.ts +++ b/db/tests/database-migration.test.ts @@ -78,6 +78,32 @@ describe("database migrations", () => { }); }, 15_000); + it("reconciles the pre-merge reply-anchor migration", async () => { + const database = createDatabase(); + await database.exec(` + CREATE TABLE chats (session_id text PRIMARY KEY); + CREATE TABLE scheduled_agent_jobs ( + id text PRIMARY KEY, + reply_anchor_message_id text + ); + `); + + await applyMigration(database, "0012_harsh_domino.sql"); + + const columns = await database.query<{ columnName: string }>(` + SELECT column_name AS "columnName" + FROM information_schema.columns + WHERE (table_name = 'chats' AND column_name = 'channel') + OR (table_name = 'scheduled_agent_jobs' AND column_name = 'reply_anchor_message_id') + ORDER BY column_name + `); + + expect(columns.rows).toEqual([ + { columnName: "channel" }, + { columnName: "reply_anchor_message_id" }, + ]); + }); + it("preserves legacy rows while enforcing constraints for new writes", async () => { const database = createDatabase(); await database.exec(legacyRuntimeSchema); From 8ff61d2a1375b7ab2999517971513b720a6740fb Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:12:51 -0400 Subject: [PATCH 4/4] Support attachments in Linq replies --- agent/channels/linq.ts | 66 +++---- agent/tools/messaging.ts | 2 +- .../@linqapp__chat-sdk-adapter@0.5.1.patch | 44 +++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + shared/chat/message-delivery.ts | 6 - .../channels/linq-message-delivery.test.ts | 185 ++++++++++++++---- 7 files changed, 231 insertions(+), 78 deletions(-) create mode 100644 patches/@linqapp__chat-sdk-adapter@0.5.1.patch diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index a610ac3c..37c0e296 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -117,14 +117,26 @@ export default linqChannel({ const idempotencyKey = report ? `scheduled-report:${report.runId}:${String(report.sequence)}` : undefined; + const adapter = context.bot.getAdapter("linq"); const post = idempotencyKey ? (content: AdapterPostableMessage) => - context.bot - .getAdapter("linq") - .postMessage(thread.id, content, { idempotencyKey }) + adapter.postMessage(thread.id, content, { idempotencyKey }) : (content: AdapterPostableMessage) => thread.post(content); + const postReply = ( + content: AdapterPostableMessage, + replyToMessageId: string + ) => { + if (idempotencyKey) { + return adapter.postMessage(thread.id, content, { + idempotencyKey, + replyToMessageId, + }); + } + return adapter.postMessage(thread.id, content, { + replyToMessageId, + }); + }; const resolveExistingChatId = () => { - const adapter = context.bot.getAdapter("linq"); const { chatId, pendingHandle } = adapter.decodeThreadId(thread.id); if (pendingHandle || !chatId) { throw new Error("A Linq reply requires an existing conversation."); @@ -177,7 +189,12 @@ export default linqChannel({ const { text: requestedText } = message.data.output; if (!requestedText) { if (attachments?.length) { - await post({ attachments, raw: "" }); + await sendLinqMessage({ + outgoing: { attachments, raw: "" }, + post, + postReply, + replyToMessageId: requestedReplyMessageId, + }); await finalizeScheduledReportDelivery(session); return; } @@ -205,12 +222,10 @@ export default linqChannel({ > = { raw: text }; if (attachments?.length) outgoing.attachments = attachments; await sendLinqMessage({ - idempotencyKey, outgoing, post, - resolveExistingChatId, - replyToMessageId: - attachments?.length === 0 ? requestedReplyMessageId : undefined, + postReply, + replyToMessageId: requestedReplyMessageId, }); await finalizeScheduledReportDelivery(session); return; @@ -242,14 +257,10 @@ export default linqChannel({ if (attachments?.length) outgoing.attachments = attachments; if (delivery.files.length > 0) outgoing.files = delivery.files; await sendLinqMessage({ - idempotencyKey, outgoing, post, - resolveExistingChatId, - replyToMessageId: - !attachments?.length && delivery.files.length === 0 - ? requestedReplyMessageId - : undefined, + postReply, + replyToMessageId: requestedReplyMessageId, }); await finalizeScheduledReportDelivery(session); } @@ -318,40 +329,27 @@ export default linqChannel({ }); async function sendLinqMessage({ - idempotencyKey, outgoing, post, - resolveExistingChatId, + postReply, replyToMessageId, }: { - readonly idempotencyKey?: string; readonly outgoing: Extract; readonly post: ( content: AdapterPostableMessage ) => Promise<{ readonly id: string }>; - readonly resolveExistingChatId: () => string; + readonly postReply: ( + content: AdapterPostableMessage, + replyToMessageId: string + ) => Promise<{ readonly id: string }>; readonly replyToMessageId?: string; }) { if (!replyToMessageId) { await post(outgoing); return; } - const chatId = resolveExistingChatId(); - const apiKey = await credentials.apiKey(); - const client = new LinqAPIV3({ apiKey }); try { - const nativeMessage: LinqMessageContent = { - parts: [{ type: "text", value: outgoing.raw }], - reply_to: { message_id: replyToMessageId }, - }; - if (idempotencyKey) { - nativeMessage.idempotency_key = idempotencyKey; - } - await client.chats.messages.send( - chatId, - { message: nativeMessage }, - undefined - ); + await postReply(outgoing, replyToMessageId); return; } catch (error) { if (!unavailableReplyTargetSchema.safeParse(error).success) throw error; diff --git a/agent/tools/messaging.ts b/agent/tools/messaging.ts index 25a6635f..2a2b2b6b 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -9,7 +9,7 @@ import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; function defineSendMessage() { return defineTool({ description: - "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined. Text is delivered exactly as written, so write it like a brief natural text message and do not use Markdown. Put nearly every response in a native quoted thread by setting replyTo: use current for an ordinary answer, clarification, status update, or follow-up prompted by the current user message, including when the user changes topics; use task with a task ID from Eve's Task state for delayed background work; and use automation with the automation ID supplied by a scheduled report. Omit replyTo only when the message is genuinely standalone and does not answer any particular user message, such as an unsolicited announcement or proactive notice, or when no applicable handle is available. Use only handles present in the current context. Replies with attachments are unsupported. Choose kind link with a URL to send a standalone native preview. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", + "Send exactly one user-visible message to the current conversation. This is the delivery path for questions, progress updates, blockers, and final answers that need words. Choose kind message for plain text, private image artifacts, and HTTPS attachments; text and attachments may be combined, including in replies. Text is delivered exactly as written, so write it like a brief natural text message and do not use Markdown. Put nearly every response in a native quoted thread by setting replyTo: use current for an ordinary answer, clarification, status update, or follow-up prompted by the current user message, including when the user changes topics; use task with a task ID from Eve's Task state for delayed background work; and use automation with the automation ID supplied by a scheduled report. Omit replyTo only when the message is genuinely standalone and does not answer any particular user message, such as an unsolicited announcement or proactive notice, or when no applicable handle is available. Use only handles present in the current context. Choose kind link with a URL to send a standalone native preview. Put an ordinary URL in message text when a preview is not wanted. Call send_message multiple times only when you intentionally want separate messages. Call it directly without an assistant-text preamble, and do not repeat delivered content afterward.", inputSchema: sendMessageOutputSchema, execute(message) { return message; diff --git a/patches/@linqapp__chat-sdk-adapter@0.5.1.patch b/patches/@linqapp__chat-sdk-adapter@0.5.1.patch new file mode 100644 index 00000000..e262c964 --- /dev/null +++ b/patches/@linqapp__chat-sdk-adapter@0.5.1.patch @@ -0,0 +1,44 @@ +diff --git a/dist/adapter.d.ts b/dist/adapter.d.ts +index d873547cafef18a8b91c9056ca84f2a44de8d7b3..68e239d42656f9249f498192bcdfd2d7dc3485ef 100644 +--- a/dist/adapter.d.ts ++++ b/dist/adapter.d.ts +@@ -32,6 +32,10 @@ export interface LinqSendOptions { + * to have any effect — a per-call value dedupes nothing. Max 255 chars. + */ + readonly idempotencyKey?: string; ++ /** ++ * Sends the message as a native threaded reply to this Linq message ID. ++ */ ++ readonly replyToMessageId?: string; + } + type LinqThreadId = { + chatId: string; +diff --git a/dist/adapter.js b/dist/adapter.js +index 67ea72ec358e21962c60f203a666bde807f96997..b4cfeed6f7160d928f4549a99cc4b09900862559 100644 +--- a/dist/adapter.js ++++ b/dist/adapter.js +@@ -169,13 +169,14 @@ class LinqAdapter { + } + const client = await this.getApiClient(); + const idempotency = options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}; ++ const reply = options.replyToMessageId ? { reply_to: { message_id: options.replyToMessageId } } : {}; + // A pending thread has no chat yet. `messages.create` lets Linq pick the + // sending line and reuses an existing chat with the same recipients, so a + // repeated first post lands in one conversation rather than forking it. + if (pendingHandle) { + const created = await client.messages.create({ + to: [pendingHandle], +- message: { parts, ...idempotency }, ++ message: { parts, ...idempotency, ...reply }, + }); + return { + id: created.message.id, +@@ -184,7 +185,7 @@ class LinqAdapter { + }; + } + const response = await client.chats.messages.send(chatId, { +- message: { parts, ...idempotency }, ++ message: { parts, ...idempotency, ...reply }, + }); + return { + id: response.message.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd311217..1ef02818 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: + '@linqapp/chat-sdk-adapter@0.5.1': 78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7 eve@0.49.0: 5b21e49f1a138ffa73b484505a6abc5a3540fc9ae32ff8d7101626fcc8c9d08d importers: @@ -143,7 +144,7 @@ importers: version: 0.5.8 '@linqapp/chat-sdk-adapter': specifier: 0.5.1 - version: 0.5.1(chat@4.34.0(ai@7.0.83(zod@4.4.3))(zod@4.4.3)) + version: 0.5.1(patch_hash=78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7)(chat@4.34.0(ai@7.0.83(zod@4.4.3))(zod@4.4.3)) '@next/env': specifier: 16.3.3 version: 16.3.3 @@ -8534,7 +8535,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@linqapp/chat-sdk-adapter@0.5.1(chat@4.34.0(ai@7.0.83(zod@4.4.3))(zod@4.4.3))': + '@linqapp/chat-sdk-adapter@0.5.1(patch_hash=78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7)(chat@4.34.0(ai@7.0.83(zod@4.4.3))(zod@4.4.3))': dependencies: '@linqapp/sdk': 0.47.1 chat: 4.34.0(ai@7.0.83(zod@4.4.3))(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96851368..eac9b0b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,4 +21,5 @@ allowBuilds: protobufjs: false sharp: true patchedDependencies: + "@linqapp/chat-sdk-adapter@0.5.1": patches/@linqapp__chat-sdk-adapter@0.5.1.patch eve@0.49.0: patches/eve@0.49.0.patch diff --git a/shared/chat/message-delivery.ts b/shared/chat/message-delivery.ts index 4a9842d6..dabcdbba 100644 --- a/shared/chat/message-delivery.ts +++ b/shared/chat/message-delivery.ts @@ -50,12 +50,6 @@ const messageOutputSchema = messageContentSchema }) .superRefine((message, context) => { requireMessageContent(message, context); - if (message.replyTo && message.attachments) { - context.addIssue({ - code: "custom", - message: "Replies currently support plain text only.", - }); - } }); const linkOutputSchema = z.strictObject({ diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index 196e18fb..0cd861d7 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -1,5 +1,8 @@ import type { LinqChannelConfig } from "eve/channels/linq"; -import type { LinqSendOptions } from "@linqapp/chat-sdk-adapter"; +import { + createLinqAdapter, + type LinqSendOptions, +} from "@linqapp/chat-sdk-adapter"; import type { LinqAPIV3 } from "@linqapp/sdk"; import type { AdapterPostableMessage } from "chat"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -210,19 +213,104 @@ describe("Linq message delivery", () => { sessionContext("test", undefined, "message-to-reply-to") ); - expect(linqChannelCapture.sendNativeMessage).toHaveBeenCalledWith( - "chat-1", + expect(linqChannelCapture.postMessage).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + { raw: "Yes, that one." }, + { replyToMessageId: "message-to-reply-to" } + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("sends an attachment-only native reply", async () => { + const { context, post } = handlerContext(); + + await handleActionResult( + sendMessageResult({ + attachments: [ + { + kind: "image", + mimeType: "image/png", + name: "result.png", + url: "https://media.example/result.png", + }, + ], + kind: "message", + replyTo: { kind: "current" }, + }), + context, + sessionContext("test", undefined, "message-to-reply-to") + ); + + expect(linqChannelCapture.postMessage).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", { - message: { - parts: [{ type: "text", value: "Yes, that one." }], - reply_to: { message_id: "message-to-reply-to" }, - }, + attachments: [ + { + mimeType: "image/png", + name: "result.png", + type: "image", + url: "https://media.example/result.png", + }, + ], + raw: "", }, - undefined + { replyToMessageId: "message-to-reply-to" } ); expect(post).not.toHaveBeenCalled(); }); + it("passes media and a reply target through the Linq adapter", async () => { + const nativeFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + chat_id: "chat-1", + message: { id: "native-message-1" }, + }), + { headers: { "content-type": "application/json" }, status: 200 } + ) + ); + const adapter = createLinqAdapter({ + apiKey: "linq-test-api-key", + baseURL: "https://linq.test", + signingSecret: "linq-test-signing-secret", + }); + let body: unknown; + + try { + await adapter.postMessage( + adapter.encodeThreadId({ chatId: "chat-1" }), + { + attachments: [ + { type: "image", url: "https://media.example/result.png" }, + ], + raw: "Here it is.", + }, + { + idempotencyKey: "reply-with-media", + replyToMessageId: "message-to-reply-to", + } + ); + expect(nativeFetch).toHaveBeenCalledOnce(); + const [input, init] = nativeFetch.mock.calls[0] ?? []; + body = await ( + input instanceof Request ? input.clone() : new Response(init?.body) + ).json(); + } finally { + nativeFetch.mockRestore(); + } + + expect(body).toEqual({ + message: { + idempotency_key: "reply-with-media", + parts: [ + { type: "text", value: "Here it is." }, + { type: "media", url: "https://media.example/result.png" }, + ], + reply_to: { message_id: "message-to-reply-to" }, + }, + }); + }); + it("falls back to a normal message when a reply handle is unavailable", async () => { const { context, post } = handlerContext(null); @@ -279,17 +367,14 @@ describe("Linq message delivery", () => { sessionContext("scheduled-result", "original-message") ); - expect(linqChannelCapture.sendNativeMessage).toHaveBeenCalledWith( - "chat-1", + expect(linqChannelCapture.postMessage).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + { raw: "Time to renew it." }, { - message: { - idempotency_key: - "scheduled-report:00000000-0000-4000-8000-000000000002:1", - parts: [{ type: "text", value: "Time to renew it." }], - reply_to: { message_id: "original-message" }, - }, - }, - undefined + idempotencyKey: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + replyToMessageId: "original-message", + } ); }); @@ -324,7 +409,7 @@ describe("Linq message delivery", () => { }); it("falls back without changing an automation anchor when the old target is unavailable", async () => { - linqChannelCapture.sendNativeMessage.mockRejectedValueOnce({ status: 404 }); + linqChannelCapture.postMessage.mockRejectedValueOnce({ status: 404 }); const { context } = handlerContext(); await handleActionResult( @@ -340,7 +425,26 @@ describe("Linq message delivery", () => { sessionContext("scheduled-result", "expired-message") ); - expect(linqChannelCapture.postMessage).toHaveBeenCalledOnce(); + expect(linqChannelCapture.postMessage).toHaveBeenCalledTimes(2); + expect(linqChannelCapture.postMessage).toHaveBeenNthCalledWith( + 1, + "linq:dm:chat-1", + { raw: "Time to renew it." }, + { + idempotencyKey: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + replyToMessageId: "expired-message", + } + ); + expect(linqChannelCapture.postMessage).toHaveBeenNthCalledWith( + 2, + "linq:dm:chat-1", + { raw: "Time to renew it." }, + { + idempotencyKey: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + } + ); }); it("uses the same Linq idempotency key when a report turn is retried", async () => { @@ -423,7 +527,7 @@ describe("Linq message delivery", () => { ).toBe(false); }); - it("accepts typed reply handles for text and native links", () => { + it("accepts typed reply handles for text, attachments, and native links", () => { expect( sendMessageOutputSchema.safeParse({ kind: "message", @@ -455,7 +559,7 @@ describe("Linq message delivery", () => { replyTo: { kind: "current" }, text: "This one.", }).success - ).toBe(false); + ).toBe(true); }); it("discriminates native links from message content", () => { @@ -548,7 +652,7 @@ describe("Linq message delivery", () => { }); }); - it("replaces scoped artifact markdown with native iMessage files", async () => { + it("threads scoped artifact files as a native reply", async () => { const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; linqChannelCapture.readImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), @@ -561,6 +665,7 @@ describe("Linq message delivery", () => { await handleActionResult( sendMessageResult({ kind: "message", + replyTo: { kind: "current" }, text: `Here it is.\n\n![Product](/artifacts/${artifactId})`, }), context, @@ -575,19 +680,24 @@ describe("Linq message delivery", () => { artifactId, { rootSessionId: "session-1", signal: undefined } ); - expect(post).toHaveBeenCalledExactlyOnceWith({ - files: [ - { - data: Buffer.from([1, 2, 3]), - filename: "product.png", - mimeType: "image/png", - }, - ], - raw: "Here it is.", - }); + expect(linqChannelCapture.postMessage).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + { + files: [ + { + data: Buffer.from([1, 2, 3]), + filename: "product.png", + mimeType: "image/png", + }, + ], + raw: "Here it is.", + }, + { replyToMessageId: "message-1" } + ); + expect(post).not.toHaveBeenCalled(); }); - it("loads scheduled artifacts from the scheduled-run session", async () => { + it("threads scheduled artifacts from the scheduled-run session", async () => { const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; linqChannelCapture.readImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), @@ -600,10 +710,14 @@ describe("Linq message delivery", () => { await handleActionResult( sendMessageResult({ kind: "message", + replyTo: { + id: "00000000-0000-4000-8000-000000000003", + kind: "automation", + }, text: `Price changed.\n\n![Product](/artifacts/${artifactId})`, }), context, - sessionContext("scheduled-result") + sessionContext("scheduled-result", "original-message") ); expect(linqChannelCapture.readImage).toHaveBeenCalledWith( @@ -623,6 +737,7 @@ describe("Linq message delivery", () => { expect.objectContaining({ idempotencyKey: "scheduled-report:00000000-0000-4000-8000-000000000002:1", + replyToMessageId: "original-message", }) ); });