/** * Tests that `handleMessageComplete` stamps a `slackMeta` sub-object on the * persisted assistant message metadata when the turn's * `assistantMessageChannel === "slack"`. * * Persistence happens BEFORE the Slack adapter sends the message, so Slack's * authoritative `ts` (-> `channelTs`) is not yet known at this layer. The * partial `slackMeta` written here is intentionally missing `channelTs`; the * post-send reconciliation step in `deliverReplyViaCallback` writes * `channelTs` back into the row once the gateway returns the Slack-assigned * ts. These tests document the persistence-side ordering — see * `channel-reply-delivery.test.ts` for the reconciliation behaviour. */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; // ── Shared mock plumbing (must precede module-under-test imports) ────────── // `addMessage` is the only DB-touching call we need to inspect. We capture // its arguments per test invocation so each case can assert on the metadata // that was actually persisted. interface AddMessageCall { id: string; conversationId: string; role: string; content: string; metadata?: Record; } const addMessageCalls: AddMessageCall[] = []; const persistedRows: Array<{ id: string; conversationId: string; role: string; content: string; createdAt: number; metadata: string | null; }> = []; mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, addMessage: ( conversationId: string, role: string, content: string, options?: { metadata?: Record }, ) => { const metadata = options?.metadata; const id = `mock-msg-${addMessageCalls.length + 1}`; addMessageCalls.push({ id, conversationId, role, content, metadata }); persistedRows.push({ id, conversationId, role, content, createdAt: Date.now(), metadata: metadata ? JSON.stringify(metadata) : null, }); return { id }; }, getConversation: () => null, getMessageById: (messageId: string) => persistedRows.find((row) => row.id === messageId) ?? null, getMessages: (conversationId: string) => persistedRows.filter((row) => row.conversationId === conversationId), updateMessageMetadata: ( messageId: string, updates: Record, ) => { const row = persistedRows.find((candidate) => candidate.id === messageId); if (!row) { return; } const existing = row.metadata && typeof row.metadata === "string" ? (JSON.parse(row.metadata) as Record) : {}; row.metadata = JSON.stringify({ ...existing, ...updates }); }, updateMessageContent: (messageId: string, content: string) => { // Mirror updateContent into the same capture array so existing // `lastAssistantPersisted()` assertions continue to find the row that // was reserved at `llm_call_started` time. const row = persistedRows.find((candidate) => candidate.id === messageId); if (row) { row.content = content; } const call = addMessageCalls.find((c) => c.id === messageId); if (call) { call.content = content; } }, markMessageContentInflight: () => {}, finalizeMessageContent: (messageId: string, content: string) => { // The finalize seam writes through `finalizeMessageContent`; mirror it // into the same captures as `updateMessageContent`. const row = persistedRows.find((candidate) => candidate.id === messageId); if (row) { row.content = content; } const call = addMessageCalls.find((c) => c.id === messageId); if (call) { call.content = content; } }, // The handler treats provenance as a flat spread; returning {} keeps the // metadata snapshot focused on the fields under test. // Mirrors the real mapping so provenance assertions observe what production // stamps rather than a placeholder. provenanceFromTrustContext: (ctx?: { trustClass?: string }) => ctx ? { provenanceTrustClass: ctx.trustClass } : {}, reserveMessage: mock( async ( conversationId: string, role: string, metadata?: Record, ) => { // B3: production code creates the assistant row at `llm_call_started` // via `reserveMessage`, stamping channel metadata at reserve time. // Mirror that into the addMessage capture array so existing // `lastAssistantPersisted()` assertions keep working. const id = `mock-msg-${addMessageCalls.length + 1}-reserve`; addMessageCalls.push({ id, conversationId, role, content: "", metadata, }); persistedRows.push({ id, conversationId, role, content: "", createdAt: Date.now(), metadata: metadata ? JSON.stringify(metadata) : null, }); return { id }; }, ), })); mock.module("../persistence/llm-request-log-store.js", () => ({ recordRequestLog: () => {}, backfillMessageIdOnLogs: () => {}, })); mock.module("../plugins/defaults/memory/memory-recall-log-store.js", () => ({ backfillMemoryRecallLogMessageId: () => {}, })); mock.module("../persistence/conversation-disk-view.js", () => ({ syncMessageToDisk: () => {}, })); let nextDeliveryTs: string | null = null; mock.module("../runtime/gateway-client.js", () => ({ deliverChannelReply: async () => ({ ok: true, ...(nextDeliveryTs ? { ts: nextDeliveryTs } : {}), }), })); mock.module("../persistence/attachments-store.js", () => ({ getAttachmentMetadataForMessage: () => [], })); // ── Imports (after mocks) ────────────────────────────────────────────────── import type { AgentEvent } from "../agent/loop.js"; import type { AssistantEvent } from "../api/index.js"; import type { EventHandlerDeps, EventHandlerState, } from "../daemon/conversation-agent-loop-handlers.js"; import { createEventHandlerState, handleLlmCallStarted, handleMessageComplete, } from "../daemon/conversation-agent-loop-handlers.js"; import { readSlackMetadata } from "../messaging/providers/slack/message-metadata.js"; import { deliverReplyViaCallback } from "../runtime/channel-reply-delivery.js"; import { setConfig } from "./helpers/set-config.js"; // ── Helpers ──────────────────────────────────────────────────────────────── function makeDeps( conversationId: string, overrides: { assistantMessageChannel?: "slack" | "vellum" | "telegram"; requesterChatId?: string; requesterTimezoneLabel?: string; clientTimezone?: string; sourceThreadId?: string; /** Turn-local trust; when set, the live slot below diverges from it. */ currentTurnTrustContext?: Record; } = {}, ): EventHandlerDeps { const assistantMessageChannel = overrides.assistantMessageChannel ?? "slack"; return { ctx: { conversationId, provider: { name: "anthropic" }, currentTurnSurfaces: [], currentTurnTrustContext: overrides.currentTurnTrustContext, trustContext: { sourceChannel: assistantMessageChannel, trustClass: "guardian", requesterChatId: overrides.requesterChatId, requesterTimezoneLabel: overrides.requesterTimezoneLabel, sourceThreadId: overrides.sourceThreadId, }, clientTimezone: overrides.clientTimezone, } as unknown as EventHandlerDeps["ctx"], onEvent: (_msg: AssistantEvent) => {}, reqId: "test-req-id", isFirstMessage: false, shouldGenerateTitle: false, rlog: new Proxy({} as Record, { get: () => () => {}, }) as unknown as EventHandlerDeps["rlog"], turnChannelContext: { userMessageChannel: assistantMessageChannel, assistantMessageChannel, } as EventHandlerDeps["turnChannelContext"], turnInterfaceContext: { userMessageInterface: assistantMessageChannel === "vellum" ? "macos" : assistantMessageChannel, assistantMessageInterface: assistantMessageChannel === "vellum" ? "macos" : assistantMessageChannel, } as EventHandlerDeps["turnInterfaceContext"], } as EventHandlerDeps; } function makeMessageCompleteEvent( text: string, ): Extract { return { type: "message_complete", message: { role: "assistant", content: [{ type: "text", text }], }, }; } /** Find the most recently persisted assistant-role message in the capture log. */ function lastAssistantPersisted(): AddMessageCall { for (let i = addMessageCalls.length - 1; i >= 0; i--) { if (addMessageCalls[i].role === "assistant") { return addMessageCalls[i]; } } throw new Error("No assistant message was persisted via addMessage"); } // ── Tests ────────────────────────────────────────────────────────────────── describe("outbound assistant Slack metadata persistence", () => { let state: EventHandlerState; beforeEach(() => { addMessageCalls.length = 0; persistedRows.length = 0; setConfig("ui", {}); nextDeliveryTs = null; state = createEventHandlerState(); state.turnStartedAt = 1_700_000_000_000; }); afterEach(() => { addMessageCalls.length = 0; persistedRows.length = 0; nextDeliveryTs = null; }); test("stamps slackMeta with threadTs from the turn's inbound thread id", async () => { const conversationId = "conv-slack-threaded"; const channelId = "C123CHANNEL"; // The turn arrived in a thread — its inbound thread ts is captured on the // trust context (`sourceThreadId`) at ingress, and the reply is stamped // from that turn-local value. const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, sourceThreadId: "1234.5678", }); await handleLlmCallStarted(state, deps); await handleMessageComplete(state, deps, makeMessageCompleteEvent("hi")); const persisted = lastAssistantPersisted(); const slackMetaRaw = persisted.metadata?.slackMeta; expect(typeof slackMetaRaw).toBe("string"); const slackMeta = JSON.parse(slackMetaRaw as string) as Record< string, unknown >; expect(slackMeta.source).toBe("slack"); expect(slackMeta.eventKind).toBe("message"); expect(slackMeta.channelId).toBe(channelId); expect(slackMeta.threadTs).toBe("1234.5678"); // Persistence runs BEFORE the Slack adapter posts the message, so the // authoritative `ts` (-> `channelTs`) is not yet known at this layer. // The post-send reconciliation in `deliverReplyViaCallback` fills the // field once the gateway returns the Slack-assigned ts (covered by // `channel-reply-delivery.test.ts`). Until that runs, the partial // metadata is intentionally rejected by `readSlackMetadata` so callers // that try to use it before reconciliation get a clear null. expect(slackMeta.channelTs).toBeUndefined(); expect(readSlackMetadata(slackMetaRaw as string)).toBeNull(); }); test("stamps assistant Slack rows with effective timestamp timezone and no speaker suffix", async () => { setConfig("ui", { userTimezone: "America/Denver" }); state.turnStartedAt = Date.parse("2026-03-05T03:38:00Z"); const conversationId = "conv-slack-timezone"; const channelId = "C999TIMEZONE"; const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, requesterTimezoneLabel: "ET", clientTimezone: "America/Los_Angeles", }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("timezone-aware reply"), ); const persisted = lastAssistantPersisted(); const slackMetaRaw = persisted.metadata?.slackMeta; expect(typeof slackMetaRaw).toBe("string"); const slackMeta = JSON.parse(slackMetaRaw as string) as Record< string, unknown >; expect(slackMeta.timestampTimezone).toBe("America/Denver"); expect(slackMeta.timestampTimezoneLabel).toBe("MT"); expect(slackMeta.speakerTimezoneLabel).toBeUndefined(); }); test("falls back to the turn client timezone when no configured user timezone is set", async () => { const conversationId = "conv-slack-client-timezone"; const channelId = "C999CLIENTTZ"; const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, clientTimezone: "America/Los_Angeles", }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("client timezone reply"), ); const persisted = lastAssistantPersisted(); const slackMeta = JSON.parse( persisted.metadata?.slackMeta as string, ) as Record; expect(slackMeta.timestampTimezone).toBe("America/Los_Angeles"); expect(slackMeta.timestampTimezoneLabel).toBe("PT"); }); test("post-send reconciliation preserves assistant Slack timezone metadata", async () => { setConfig("ui", { userTimezone: "America/Denver" }); const conversationId = "conv-slack-reconcile-timezone"; const channelId = "C999RECONCILE"; const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, requesterTimezoneLabel: "ET", }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("delivery reconciliation reply"), ); const persisted = lastAssistantPersisted(); const beforeRaw = persisted.metadata?.slackMeta; expect(typeof beforeRaw).toBe("string"); expect(readSlackMetadata(beforeRaw as string)).toBeNull(); nextDeliveryTs = "1772678280.000200"; await deliverReplyViaCallback( conversationId, channelId, "http://gateway/deliver/slack", "assistant-1", { messageId: persisted.id }, ); const row = persistedRows.find( (candidate) => candidate.id === persisted.id, ); expect(typeof row?.metadata).toBe("string"); const envelope = JSON.parse(row!.metadata!) as Record; const reconciled = readSlackMetadata(envelope.slackMeta as string); expect(reconciled).not.toBeNull(); expect(reconciled!.channelTs).toBe("1772678280.000200"); expect(reconciled!.timestampTimezone).toBe("America/Denver"); expect(reconciled!.timestampTimezoneLabel).toBe("MT"); expect(reconciled!.speakerTimezoneLabel).toBeUndefined(); }); test("stamps slackMeta WITHOUT threadTs for top-level Slack replies", async () => { const conversationId = "conv-slack-toplevel"; const channelId = "C456NOTHREAD"; // The turn arrived at the channel root — no `sourceThreadId` on the trust // context, so the reply targets the channel root, not a thread. const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, }); await handleLlmCallStarted(state, deps); await handleMessageComplete(state, deps, makeMessageCompleteEvent("hello")); const persisted = lastAssistantPersisted(); const slackMetaRaw = persisted.metadata?.slackMeta; expect(typeof slackMetaRaw).toBe("string"); const slackMeta = JSON.parse(slackMetaRaw as string) as Record< string, unknown >; expect(slackMeta.source).toBe("slack"); expect(slackMeta.eventKind).toBe("message"); expect(slackMeta.channelId).toBe(channelId); // threadTs is intentionally absent — top-level reply. expect(slackMeta.threadTs).toBeUndefined(); // channelTs is still absent for the same persistence-vs-send reason. expect(slackMeta.channelTs).toBeUndefined(); }); test("envelope resolves from the turn's actor when the slot has moved", async () => { // Provenance class and Slack routing must come from ONE actor: the turn's. // A queued Slack turn runs while the guardian has since written the live // slot; the assistant row must carry the sender's class AND the sender's // channel/thread, not a mixed envelope. const conversationId = "conv-slack-moved-slot"; const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", // Live slot: the guardian, on a different chat, no thread. requesterChatId: "C-GUARDIAN", currentTurnTrustContext: { sourceChannel: "slack", trustClass: "trusted_contact", requesterChatId: "C-CONTACT", sourceThreadId: "1723300000.000100", }, }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("reply to the contact"), ); const persisted = lastAssistantPersisted(); expect(persisted.metadata?.provenanceTrustClass).toBe("trusted_contact"); const slackMeta = JSON.parse( persisted.metadata?.slackMeta as string, ) as Record; expect(slackMeta.channelId).toBe("C-CONTACT"); expect(slackMeta.threadTs).toBe("1723300000.000100"); }); test("ignores a non-ts sourceThreadId", async () => { const conversationId = "conv-slack-bad-thread"; const channelId = "C789BAD"; // A malformed thread id must not be stamped as a threadTs (isSlackTs guard). const deps = makeDeps(conversationId, { assistantMessageChannel: "slack", requesterChatId: channelId, sourceThreadId: "not-a-ts", }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("root reply"), ); const persisted = lastAssistantPersisted(); const slackMetaRaw = persisted.metadata?.slackMeta; expect(typeof slackMetaRaw).toBe("string"); const slackMeta = JSON.parse(slackMetaRaw as string) as Record< string, unknown >; expect(slackMeta.threadTs).toBeUndefined(); }); test("does NOT stamp slackMeta on non-Slack outbound assistant messages", async () => { const conversationId = "conv-vellum"; const deps = makeDeps(conversationId, { assistantMessageChannel: "vellum", }); await handleLlmCallStarted(state, deps); await handleMessageComplete( state, deps, makeMessageCompleteEvent("vellum reply"), ); const persisted = lastAssistantPersisted(); expect(persisted.metadata).toBeDefined(); // Non-Slack channels must leave the existing metadata shape untouched. expect(persisted.metadata?.slackMeta).toBeUndefined(); }); });