diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index ee4d0acb..37c0e296 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -8,12 +8,13 @@ import { } from "eve/channels/linq"; import { vercelOidc } from "eve/channels/auth"; import { z } from "zod"; +import { resolveLinqReplyTarget } from "@agent/lib/reply-targets"; +import { scopeFromPrincipal } from "@agent/lib/principal-scope"; import { getAuth } from "@db/services/auth"; -import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; -import { normalizeAuthPhoneNumber } from "@shared/identity/phone-number"; -import { scopeFromPrincipal } from "@agent/lib/principal-scope"; +import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; import { accessScopeForUser } from "@shared/identity/access-scope"; +import { normalizeAuthPhoneNumber } from "@shared/identity/phone-number"; import { prepareLinqImageArtifactDelivery } from "../lib/linq-image-artifact/delivery"; import { extractImageArtifactMarkdownReferences, @@ -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,35 +106,79 @@ 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; + 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); - - if (message.data.output.kind === "link") { - const adapter = context.bot.getAdapter("linq"); + 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 { 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; } @@ -137,7 +189,14 @@ 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; } await finalizeScheduledReportDelivery(session); return; @@ -162,7 +221,12 @@ export default linqChannel({ { raw: string } > = { raw: text }; if (attachments?.length) outgoing.attachments = attachments; - await post(outgoing); + await sendLinqMessage({ + outgoing, + post, + postReply, + replyToMessageId: requestedReplyMessageId, + }); await finalizeScheduledReportDelivery(session); return; } @@ -192,7 +256,12 @@ 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({ + outgoing, + post, + postReply, + replyToMessageId: requestedReplyMessageId, + }); await finalizeScheduledReportDelivery(session); } }, @@ -249,6 +318,7 @@ export default linqChannel({ conversationChannel: "linq", conversationId: context.thread.id, linqThreadId: context.thread.id, + linqMessageId: message.id, phoneNumber, workspaceId: scope.workspaceId, }, @@ -258,6 +328,38 @@ export default linqChannel({ }, }); +async function sendLinqMessage({ + outgoing, + post, + postReply, + replyToMessageId, +}: { + readonly outgoing: Extract; + readonly post: ( + content: AdapterPostableMessage + ) => Promise<{ readonly id: string }>; + readonly postReply: ( + content: AdapterPostableMessage, + replyToMessageId: string + ) => Promise<{ readonly id: string }>; + readonly replyToMessageId?: string; +}) { + if (!replyToMessageId) { + await post(outgoing); + return; + } + try { + await postReply(outgoing, replyToMessageId); + 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..e71c90e6 --- /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..a2305856 --- /dev/null +++ b/agent/lib/reply-targets.ts @@ -0,0 +1,83 @@ +import { defineState, type SessionAuth } from "eve/context"; +import { z } from "zod"; +import { scheduledReportIdentity } from "@agent/lib/schedules/identity"; +import type { ReplyReference } from "@shared/chat/message-delivery"; + +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 837c4615..e050a0bb 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 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 [ "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 50701d96..4a45527f 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/tools/messaging.ts b/agent/tools/messaging.ts index 917d48d0..2a2b2b6b 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -6,23 +6,27 @@ import { } from "@shared/chat/reaction"; 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, 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; + }, + 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 72da780c..67fff4f4 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 "@shared/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/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts b/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts index 5ba4affa..f4cf866b 100644 --- a/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts +++ b/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/db/migrations/0012_harsh_domino.sql b/db/migrations/0012_harsh_domino.sql new file mode 100644 index 00000000..dc7b4e7a --- /dev/null +++ b/db/migrations/0012_harsh_domino.sql @@ -0,0 +1,3 @@ +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/migrations/meta/0012_snapshot.json b/db/migrations/meta/0012_snapshot.json new file mode 100644 index 00000000..9c633a04 --- /dev/null +++ b/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,1949 @@ +{ + "id": "ce48709e-63fe-4dd6-b07c-9bfea0ea48b6", + "prevId": "c4d701c1-3269-4972-b750-3f136b83f5b9", + "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 + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 cc0bed63..74f8a0bc 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1788449992663, "tag": "0011_faulty_unicorn", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1788537134684, + "tag": "0012_harsh_domino", + "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 757c9a81..1706833c 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 12afb1fd..29653c86 100644 --- a/db/tests/database-migration.test.ts +++ b/db/tests/database-migration.test.ts @@ -39,6 +39,7 @@ describe("database migrations", () => { await applyMigration(database, "0009_cold_power_man.sql"); await applyMigration(database, "0010_rapid_cerise.sql"); await applyMigration(database, "0011_faulty_unicorn.sql"); + await applyMigration(database, "0012_harsh_domino.sql"); const tables = await database.query<{ count: number }>( `SELECT count(*)::int AS count @@ -77,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); diff --git a/db/tests/scheduled-agent-jobs.test.ts b/db/tests/scheduled-agent-jobs.test.ts index 3b2ce7ea..317024ff 100644 --- a/db/tests/scheduled-agent-jobs.test.ts +++ b/db/tests/scheduled-agent-jobs.test.ts @@ -30,6 +30,8 @@ describe("scheduled agent jobs", () => { "0008_black_sandman.sql", "0009_cold_power_man.sql", "0010_rapid_cerise.sql", + "0011_faulty_unicorn.sql", + "0012_harsh_domino.sql", ]) { await applyMigration(client, migration); } diff --git a/evals/agent/conversation.eval.ts b/evals/agent/conversation.eval.ts index 6a6bf571..c1879543 100644 --- a/evals/agent/conversation.eval.ts +++ b/evals/agent/conversation.eval.ts @@ -1,5 +1,6 @@ import { defineEval, type EveEvalContext } from "eve/evals"; import { includes, satisfies } from "eve/evals/expect"; +import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; import { reactToMessageOutputSchema } from "@shared/chat/reaction"; import { agentEvalTags, @@ -146,4 +147,209 @@ const reactionEvals = [ }), ]; -export default [...textEvals, ...reactionEvals]; +const replyEvals = [ + 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: "browser-agent", + 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 })}. 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.", + ], + }); + 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: "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."); + 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?.includes("23") === true + ); + }, + status: "completed", + }); + turn.notCalledTool("react_to_message"); + turn.maxToolCalls(1); + }, + }), + defineEval({ + 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." + ); + 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?.kind === "current" && + /boston/iu.test(parsed.data.text ?? "") + ); + }, + status: "completed", + }); + answer.notCalledTool("react_to_message"); + answer.maxToolCalls(1); + }, + }), + defineEval({ + 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."); + 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?.kind === "current" && + /paris/iu.test(parsed.data.text ?? "") + ); + }, + status: "completed", + }); + second.notCalledTool("react_to_message"); + 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/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 f441684b..dabcdbba 100644 --- a/shared/chat/message-delivery.ts +++ b/shared/chat/message-delivery.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,37 @@ 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) { - context.addIssue({ - code: "custom", - message: "A message must include text or at least one attachment.", - }); - } + requireMessageContent(message, context); }); const linkOutputSchema = z.strictObject({ kind: z.literal("link"), + replyTo: replyReferenceSchema.optional(), url: nativeLinkSchema, }); diff --git a/tests/agent/channels/eve-message-delivery.test.ts b/tests/agent/channels/eve-message-delivery.test.ts index be3bc6d9..e047d15e 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 456c7757..383518d9 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 09bf4efc..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"; @@ -26,6 +29,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 +53,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 +65,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 +200,133 @@ 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.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", + { + attachments: [ + { + mimeType: "image/png", + name: "result.png", + type: "image", + url: "https://media.example/result.png", + }, + ], + raw: "", + }, + { 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); + + 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 +351,102 @@ 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.postMessage).toHaveBeenCalledExactlyOnceWith( + "linq:dm:chat-1", + { raw: "Time to renew it." }, + { + idempotencyKey: + "scheduled-report:00000000-0000-4000-8000-000000000002:1", + replyToMessageId: "original-message", + } + ); + }); + + 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.postMessage.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).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 () => { const { context } = handlerContext(); const event = sendMessageResult({ @@ -299,6 +527,41 @@ describe("Linq message delivery", () => { ).toBe(false); }); + it("accepts typed reply handles for text, attachments, 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(true); + }); + it("discriminates native links from message content", () => { expect( sendMessageOutputSchema.safeParse({ @@ -349,7 +612,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({ @@ -389,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]), @@ -402,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, @@ -416,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]), @@ -441,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( @@ -464,6 +737,7 @@ describe("Linq message delivery", () => { expect.objectContaining({ idempotencyKey: "scheduled-report:00000000-0000-4000-8000-000000000002:1", + replyToMessageId: "original-message", }) ); }); @@ -624,9 +898,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 +948,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..7118c53c --- /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: "browser-agent", + }, + 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 e45cd082..4c04ba92 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,21 @@ 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}"}` + ); + 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 () => { @@ -300,6 +313,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 1959ab09..2719417f 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: {