import { beforeEach, describe, expect, it, mock } from "bun:test"; import { resolveMessageContentBlocks } from "../persistence/message-content-file.js"; import type { RuntimeAttachmentMetadata } from "../runtime/http-types.js"; type DeliveryCall = { callbackUrl: string; payload: Record; }; const deliveryCalls: DeliveryCall[] = []; type MockMessageRow = { id: string; role: string; content: string; metadata?: string | null; }; const conversationMessages: MockMessageRow[] = []; const attachmentsByMessageId = new Map< string, Array<{ id: string; originalFilename?: string; mimeType?: string; sizeBytes?: number; kind?: string; }> >(); type UpdateMessageMetadataCall = { messageId: string; updates: Record; }; const updateMessageMetadataCalls: UpdateMessageMetadataCall[] = []; /** Per-test override for the synthetic Slack `ts` returned by deliverChannelReply. */ let nextDeliveryTs: string | null = null; type RenderedHistoryStub = { text: string; textSegments: string[]; toolCalls: unknown[]; toolCallsBeforeText: boolean; contentOrder: string[]; surfaces: unknown[]; thinkingSegments: string[]; }; let renderedHistoryContent: RenderedHistoryStub = { text: "", textSegments: [], toolCalls: [], toolCallsBeforeText: false, contentOrder: [], surfaces: [], thinkingSegments: [], }; const renderedHistoryContentQueue: RenderedHistoryStub[] = []; let deliveryFailAtIndex = -1; const editCalls: { callbackUrl: string; target: Record }[] = []; mock.module("../messaging/providers/index.js", () => ({ editChannelMessage: async ( callbackUrl: string, target: Record, ) => { editCalls.push({ callbackUrl, target }); return { ok: true }; }, })); mock.module("../runtime/gateway-client.js", () => ({ deliverChannelReply: async ( callbackUrl: string, payload: Record, ) => { if ( deliveryFailAtIndex >= 0 && deliveryCalls.length === deliveryFailAtIndex ) { throw new Error("Simulated delivery failure (502)"); } deliveryCalls.push({ callbackUrl, payload }); if (nextDeliveryTs !== null) { const ts = nextDeliveryTs; // Only the first segment of a multi-segment delivery should carry // back a meaningful ts for `channelTs` reconciliation. Tests that // need specific ts values per-segment can re-set this between calls. return { ok: true, ts }; } return { ok: true }; }, })); mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, setConversationOriginChannelIfUnset: () => {}, updateConversationContextWindow: () => {}, deleteMessageById: () => {}, updateConversationTitle: () => {}, updateConversationUsage: () => {}, addMessage: () => ({ id: "mock-msg-id" }), getConversation: () => ({ id: "conv-1", contextSummary: null, contextCompactedMessageCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalEstimatedCost: 0, title: null, }), provenanceFromTrustContext: () => ({ source: "user", trustContext: undefined, }), getConversationOriginInterface: () => null, getConversationOriginChannel: () => null, getMessages: () => conversationMessages.map((m) => ({ ...m, content: resolveMessageContentBlocks(m.content), })), getMessagesAfter: ( _conversationId: string, afterMessageId: string | null, ) => { const resolved = conversationMessages.map((m) => ({ ...m, content: resolveMessageContentBlocks(m.content), })); if (!afterMessageId) { return resolved; } const index = resolved.findIndex( (message) => message.id === afterMessageId, ); return index === -1 ? [] : resolved.slice(index + 1); }, getMessageById: (messageId: string) => conversationMessages.find((m) => m.id === messageId) ?? null, updateMessageMetadata: ( messageId: string, updates: Record, ) => { updateMessageMetadataCalls.push({ messageId, updates }); const row = conversationMessages.find((m) => m.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 }); }, reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../persistence/attachments-store.js", () => ({ getAttachmentMetadataForMessage: (messageId: string) => attachmentsByMessageId.get(messageId) ?? [], getFilePathForAttachment: () => null, })); mock.module("../daemon/handlers/shared.js", () => ({ renderHistoryContent: () => renderedHistoryContentQueue.shift() ?? renderedHistoryContent, })); const { deliverRenderedReplyViaCallback, deliverReplyViaCallback, findAssistantReplyMessageIdForTurn, } = await import("../runtime/channel-reply-delivery.js"); describe("channel-reply-delivery", () => { beforeEach(() => { deliveryCalls.length = 0; deliveryFailAtIndex = -1; conversationMessages.length = 0; attachmentsByMessageId.clear(); updateMessageMetadataCalls.length = 0; nextDeliveryTs = null; renderedHistoryContentQueue.length = 0; renderedHistoryContent = { text: "", textSegments: [], toolCalls: [], toolCallsBeforeText: false, contentOrder: [], surfaces: [], thinkingSegments: [], }; }); it("finds the assistant reply in the linked user turn", () => { conversationMessages.push( { id: "user-target", role: "user", content: "target", }, { id: "assistant-tool-call", role: "assistant", content: "tool call", }, { id: "assistant-target", role: "assistant", content: "final reply", }, { id: "user-newer", role: "user", content: "newer", }, { id: "assistant-newer", role: "assistant", content: "newer reply", }, ); renderedHistoryContentQueue.push({ text: "Final reply.", textSegments: ["Final reply."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }); expect(findAssistantReplyMessageIdForTurn("conv-1", "user-target")).toBe( "assistant-target", ); }); it("sends non-empty text segments as separate messages and puts attachments on the last segment", async () => { const attachments: RuntimeAttachmentMetadata[] = [ { id: "att-1", filename: "file.txt", mimeType: "text/plain", sizeBytes: 5, kind: "uploaded", }, ]; await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-1", textSegments: ["Before tool.", " ", "", "After tool."], fallbackText: "Before tool.After tool.", attachments, assistantId: "assistant-1", interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(2); expect(deliveryCalls[0]).toEqual({ callbackUrl: "http://gateway/deliver/telegram", payload: { chatId: "chat-1", text: "Before tool.", useBlocks: true, attachments: undefined, assistantId: "assistant-1", }, }); expect(deliveryCalls[1]).toEqual({ callbackUrl: "http://gateway/deliver/telegram", payload: { chatId: "chat-1", text: "After tool.", useBlocks: true, attachments, assistantId: "assistant-1", }, }); }); it("falls back to rendered.text when no non-empty textSegments exist", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-2", textSegments: [" ", ""], fallbackText: "Fallback text", interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Fallback text"); }); it("uses rendered textSegments (tool boundaries) when delivering from conversation history", async () => { conversationMessages.push( { id: "msg-user", role: "user", content: "hi" }, { id: "msg-assistant", role: "assistant", content: '[{"type":"text","text":"ignored"}]', }, ); attachmentsByMessageId.set("msg-assistant", [ { id: "att-2", originalFilename: "log.txt", mimeType: "text/plain", sizeBytes: 42, kind: "uploaded", }, ]); renderedHistoryContent = { text: "Before tool.After tool.", textSegments: ["Before tool.", "After tool."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0", "tool:0", "text:1"], surfaces: [], thinkingSegments: [], }; await deliverReplyViaCallback( "conv-1", "chat-3", "http://gateway/deliver/telegram", "assistant-2", ); expect(deliveryCalls).toHaveLength(2); expect(deliveryCalls[0].payload).toEqual({ chatId: "chat-3", text: "Before tool.", useBlocks: true, attachments: undefined, assistantId: "assistant-2", }); expect(deliveryCalls[1].payload).toEqual({ chatId: "chat-3", text: "After tool.", useBlocks: true, attachments: [ { id: "att-2", filename: "log.txt", mimeType: "text/plain", sizeBytes: 42, kind: "uploaded", }, ], assistantId: "assistant-2", }); }); it("falls back to current-turn assistant text when the newest assistant row is tool-only", async () => { conversationMessages.push( { id: "msg-old-user", role: "user", content: "old prompt" }, { id: "msg-old-assistant", role: "assistant", content: '[{"type":"text","text":"old answer"}]', }, { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-text", role: "assistant", content: '[{"type":"text","text":"current answer"}]', }, { id: "msg-current-tool-result", role: "user", content: '[{"type":"tool_result","tool_use_id":"tu-1","content":"ok"}]', }, { id: "msg-current-tool-only", role: "assistant", content: '[{"type":"tool_use","id":"tu-2","name":"remember","input":{}}]', }, ); renderedHistoryContentQueue.push( { text: "", textSegments: [], toolCalls: [{ name: "remember", input: {} }], toolCallsBeforeText: true, contentOrder: ["tool:0"], surfaces: [], thinkingSegments: [], }, { text: "Current answer.", textSegments: ["Current answer."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }, { text: "Current answer.", textSegments: ["Current answer."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }, ); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Current answer."); }); it("does not cross the current user boundary when no current-turn assistant text exists", async () => { conversationMessages.push( { id: "msg-old-user", role: "user", content: "old prompt" }, { id: "msg-old-assistant", role: "assistant", content: '[{"type":"text","text":"old answer"}]', }, { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-tool-only", role: "assistant", content: '[{"type":"tool_use","id":"tu-1","name":"remember","input":{}}]', }, ); renderedHistoryContentQueue.push({ text: "", textSegments: [], toolCalls: [{ name: "remember", input: {} }], toolCallsBeforeText: true, contentOrder: ["tool:0"], surfaces: [], thinkingSegments: [], }); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(0); }); // Silence means the turn produced no real reply text anywhere — not "the // last row was a sentinel". A trailing bare row must not // swallow the real reply written earlier in the same turn. it("delivers the earlier real reply when the turn ends with a bare no_response row", async () => { conversationMessages.push( { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-text", role: "assistant", content: '[{"type":"text","text":"current answer"}]', }, { id: "msg-current-silent", role: "assistant", content: '[{"type":"text","text":""}]', }, ); const silentStub = { text: "", textSegments: [""], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; const answerStub = { text: "current answer", textSegments: ["current answer"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; // Turn scan reads the silent row then the text row; delivery re-reads // the chosen text row. renderedHistoryContentQueue.push(silentStub, answerStub, answerStub); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("current answer"); }); it("stays silent when a no_response turn has no real reply text anywhere", async () => { conversationMessages.push( { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-silent", role: "assistant", content: '[{"type":"text","text":""}]', }, ); const silentStub = { text: "", textSegments: [""], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; // Turn scan reads the silent row; delivery re-reads it as the terminal // deliberate-silence target. renderedHistoryContentQueue.push(silentStub, silentStub); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(0); }); it("falls through a messageId-targeted bare no_response row to the turn's real reply", async () => { conversationMessages.push( { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-text", role: "assistant", content: '[{"type":"text","text":"current answer"}]', }, { id: "msg-current-silent", role: "assistant", content: '[{"type":"text","text":""}]', }, ); const silentStub = { text: "", textSegments: [""], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; const answerStub = { text: "current answer", textSegments: ["current answer"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; // messageId branch reads the targeted silent row, the turn scan reads // the silent row then the text row, and delivery re-reads the text row. renderedHistoryContentQueue.push( silentStub, silentStub, answerStub, answerStub, ); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { messageId: "msg-current-silent", sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("current answer"); }); // A bare-sentinel row never delivers its attachments (marker rows suppress // attachment delivery), so attachments alone must not make the row count // as the turn's real reply and stop the fall-through. it("falls through a bare no_response row with attachments to the turn's real reply", async () => { conversationMessages.push( { id: "msg-current-user", role: "user", content: "current prompt" }, { id: "msg-current-text", role: "assistant", content: '[{"type":"text","text":"current answer"}]', }, { id: "msg-current-silent", role: "assistant", content: '[{"type":"text","text":""}]', }, ); attachmentsByMessageId.set("msg-current-silent", [ { id: "att-silent", originalFilename: "chart.png", mimeType: "image/png", sizeBytes: 10, kind: "generated", }, ]); const silentStub = { text: "", textSegments: [""], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; const answerStub = { text: "current answer", textSegments: ["current answer"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; // messageId branch reads the targeted silent row, the turn scan reads // the silent row then the text row, and delivery re-reads the text row. renderedHistoryContentQueue.push( silentStub, silentStub, answerStub, answerStub, ); await deliverReplyViaCallback( "conv-1", "chat-current", "http://gateway/deliver/slack", "assistant-current", { messageId: "msg-current-silent", sinceMessageId: "msg-current-user" }, ); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("current answer"); }); it("skips already-delivered segments when startFromSegment is set", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-resume", textSegments: ["Segment A.", "Segment B.", "Segment C."], interSegmentDelayMs: 0, startFromSegment: 1, }); // Should only deliver segments B and C (indices 1 and 2) expect(deliveryCalls).toHaveLength(2); expect(deliveryCalls[0].payload.text).toBe("Segment B."); expect(deliveryCalls[1].payload.text).toBe("Segment C."); }); it("calls onSegmentDelivered after each successful segment", async () => { const delivered: number[] = []; await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-progress", textSegments: ["Part 1.", "Part 2.", "Part 3."], interSegmentDelayMs: 0, onSegmentDelivered: (count) => delivered.push(count), }); expect(delivered).toEqual([1, 2, 3]); expect(deliveryCalls).toHaveLength(3); }); it("does not call onSegmentDelivered for a failing segment", async () => { const delivered: number[] = []; deliveryFailAtIndex = 2; try { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-fail", textSegments: ["Part 1.", "Part 2.", "Part 3."], interSegmentDelayMs: 0, onSegmentDelivered: (count) => delivered.push(count), }); } catch { // Expected failure on third segment } // Only segments 0 and 1 were delivered, callback was called twice expect(delivered).toEqual([1, 2]); expect(deliveryCalls).toHaveLength(2); }); it("resumes delivery after partial failure using startFromSegment", async () => { const delivered: number[] = []; // First attempt: fails on third segment (index 2) deliveryFailAtIndex = 2; try { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-retry", textSegments: ["Seg A.", "Seg B.", "Seg C."], interSegmentDelayMs: 0, onSegmentDelivered: (count) => delivered.push(count), }); } catch { // Expected } expect(delivered).toEqual([1, 2]); expect(deliveryCalls).toHaveLength(2); // Reset for retry deliveryCalls.length = 0; delivered.length = 0; deliveryFailAtIndex = -1; // Retry: start from segment 2 (the last delivered count) await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-retry", textSegments: ["Seg A.", "Seg B.", "Seg C."], interSegmentDelayMs: 0, startFromSegment: 2, onSegmentDelivered: (count) => delivered.push(count), }); // Only segment C should be delivered expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Seg C."); expect(delivered).toEqual([3]); }); it("skips all segments when startFromSegment equals total count", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-done", textSegments: ["Done A.", "Done B."], interSegmentDelayMs: 0, startFromSegment: 2, }); // All segments already delivered, nothing to send expect(deliveryCalls).toHaveLength(0); }); it("updates a live-delivered message when skipped text has attachments", async () => { const seenTs: string[] = []; const attachments: RuntimeAttachmentMetadata[] = [ { id: "attachment-1", filename: "report.txt", mimeType: "text/plain", sizeBytes: 12, kind: "file", }, ]; await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-live", textSegments: ["Already sent live."], attachments, startFromSegment: 1, messageTs: "1700000000.000055", onMessageTs: (ts) => seenTs.push(ts), }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload).toEqual({ chatId: "chat-live", attachments, assistantId: undefined, audience: undefined, }); // Attachments post as new messages, so nothing is edited on this path. expect(editCalls).toHaveLength(0); expect(seenTs).toEqual(["1700000000.000055"]); }); it("carries the audience through to every delivery call", async () => { const audience = { kind: "oneReader", userId: "U456" } as const; await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "C123", textSegments: ["Part 1.", "Part 2."], interSegmentDelayMs: 0, audience, }); expect(deliveryCalls).toHaveLength(2); // Every segment, not just the first: a reply restricted to one reader // that loses the restriction partway becomes a public one. for (const call of deliveryCalls) { expect(call.payload.audience).toEqual(audience); } }); it("leaves the audience unset when the reply is for the room", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "C123", textSegments: ["Normal message."], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.audience).toBeUndefined(); }); it("suppresses delivery when the only text segment is ", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-silent", textSegments: [""], fallbackText: "Fallback text", interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(0); }); it("suppresses attachment delivery when is present", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-silent-att", textSegments: [""], attachments: [ { id: "att-no-resp", filename: "secret.txt", mimeType: "text/plain", sizeBytes: 10, kind: "uploaded", }, ], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(0); }); it("suppresses delivery for with surrounding whitespace", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-silent-ws", textSegments: [" "], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(0); }); it("delivers other segments when is mixed with real text", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-mixed", textSegments: ["", "Real response."], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Real response."); }); it("strips a prefixed inline and delivers the rest of the segment", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-inline-prefix", textSegments: ["\n\nReal reply."], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Real reply."); }); it("strips a trailing inline and delivers the rest of the segment", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-inline-trailing", textSegments: ["Real reply.\n\n"], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Real reply."); }); it("never leaks the sentinel into delivered text, including the fallback path", async () => { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/telegram", chatId: "chat-fallback-strip", textSegments: [], fallbackText: "Fallback reply. ", interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.text).toBe("Fallback reply."); for (const call of deliveryCalls) { expect(String(call.payload.text)).not.toContain(" { await deliverRenderedReplyViaCallback({ callbackUrl: "http://gateway/deliver/slack", chatId: "chat-silent-case", textSegments: [""], interSegmentDelayMs: 0, }); expect(deliveryCalls).toHaveLength(0); }); it("passes startFromSegment through deliverReplyViaCallback options", async () => { conversationMessages.push( { id: "msg-u", role: "user", content: "hi" }, { id: "msg-a", role: "assistant", content: '"text"' }, ); renderedHistoryContent = { text: "Alpha.Beta.Gamma.", textSegments: ["Alpha.", "Beta.", "Gamma."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0", "tool:0", "text:1", "tool:1", "text:2"], surfaces: [], thinkingSegments: [], }; const delivered: number[] = []; await deliverReplyViaCallback( "conv-resume", "chat-resume", "http://gateway/deliver/telegram", "assistant-3", { startFromSegment: 1, onSegmentDelivered: (count) => delivered.push(count), }, ); // Should skip 'Alpha.' and deliver 'Beta.' and 'Gamma.' expect(deliveryCalls).toHaveLength(2); expect(deliveryCalls[0].payload.text).toBe("Beta."); expect(deliveryCalls[1].payload.text).toBe("Gamma."); expect(delivered).toEqual([2, 3]); }); it("targets an explicit assistant message instead of the latest reply", async () => { conversationMessages.push( { id: "msg-u", role: "user", content: "hi" }, { id: "msg-old", role: "assistant", content: '"old reply"' }, { id: "msg-new", role: "assistant", content: '"new reply"' }, ); attachmentsByMessageId.set("msg-old", [ { id: "att-old", originalFilename: "old.txt", mimeType: "text/plain", sizeBytes: 11, kind: "uploaded", }, ]); attachmentsByMessageId.set("msg-new", [ { id: "att-new", originalFilename: "new.txt", mimeType: "text/plain", sizeBytes: 22, kind: "uploaded", }, ]); renderedHistoryContent = { text: "Reply.", textSegments: ["Reply."], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; await deliverReplyViaCallback( "conv-target", "chat-target", "http://gateway/deliver/telegram", "assistant-3", { messageId: "msg-old" }, ); expect(deliveryCalls).toHaveLength(1); expect(deliveryCalls[0].payload.attachments).toEqual([ { id: "att-old", filename: "old.txt", mimeType: "text/plain", sizeBytes: 11, kind: "uploaded", }, ]); }); it("rejects an explicit target that is not an assistant message", async () => { conversationMessages.push({ id: "msg-u", role: "user", content: "hi" }); await expect( deliverReplyViaCallback( "conv-target", "chat-target", "http://gateway/deliver/telegram", "assistant-3", { messageId: "msg-u" }, ), ).rejects.toThrow("Target assistant reply message not found"); }); // ── slackMeta.channelTs reconciliation (post-send) ───────────────────── // These tests close the gap where outbound assistant messages were // persisted with a partial slackMeta lacking `channelTs`. The renderer // (`readSlackMetadata`) rejects rows missing `channelTs`, so without // reconciliation every outbound assistant row falls through to the // legacy/flat fallback and is excluded from thread-tag rendering and the // active-thread focus block. describe("slackMeta.channelTs reconciliation", () => { /** Build the outer envelope mirroring `handleMessageComplete`'s write. */ function partialSlackEnvelope( channelId: string, threadTs?: string, ): string { // Note: this matches the partial write — channelTs is intentionally // absent so `readSlackMetadata` returns null until reconciliation runs. const inner: Record = { source: "slack", eventKind: "message", channelId, ...(threadTs ? { threadTs } : {}), }; return JSON.stringify({ userMessageChannel: "slack", assistantMessageChannel: "slack", slackMeta: JSON.stringify(inner), }); } function pushPartialAssistantRow( conversationId: string, messageId: string, channelId: string, threadTs?: string, ): void { conversationMessages.push({ id: messageId, role: "assistant", content: '[{"type":"text","text":"hello"}]', metadata: partialSlackEnvelope(channelId, threadTs), }); // Set up renderer to produce one segment so onMessageTs fires once. renderedHistoryContent = { text: "hello", textSegments: ["hello"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; } it("writes channelTs into slackMeta from the gateway-returned ts (top-level reply)", async () => { pushPartialAssistantRow("conv-recon-top", "msg-recon-top", "C123"); nextDeliveryTs = "1700000123.000456"; await deliverReplyViaCallback( "conv-recon-top", "C123", "http://gateway/deliver/slack", "assistant-recon", ); expect(updateMessageMetadataCalls.length).toBe(1); const call = updateMessageMetadataCalls[0]; expect(call.messageId).toBe("msg-recon-top"); const merged = call.updates.slackMeta as string; expect(typeof merged).toBe("string"); const parsed = JSON.parse(merged) as Record; expect(parsed.source).toBe("slack"); expect(parsed.channelId).toBe("C123"); expect(parsed.eventKind).toBe("message"); expect(parsed.channelTs).toBe("1700000123.000456"); expect(parsed.threadTs).toBeUndefined(); }); it("preserves an existing threadTs when reconciling channelTs (threaded reply)", async () => { pushPartialAssistantRow( "conv-recon-thread", "msg-recon-thread", "C456", "1234.5678", ); nextDeliveryTs = "1700000200.000700"; await deliverReplyViaCallback( "conv-recon-thread", "C456", "http://gateway/deliver/slack", "assistant-recon-thread", ); expect(updateMessageMetadataCalls.length).toBe(1); const merged = updateMessageMetadataCalls[0].updates.slackMeta as string; const parsed = JSON.parse(merged) as Record; expect(parsed.threadTs).toBe("1234.5678"); expect(parsed.channelTs).toBe("1700000200.000700"); }); it("does NOT call updateMessageMetadata when the assistant row has no slackMeta", async () => { // vellum/telegram/non-slack outbound: the row's metadata envelope has // no slackMeta sub-key. The reconciler must short-circuit silently. conversationMessages.push({ id: "msg-vellum", role: "assistant", content: '[{"type":"text","text":"hi"}]', metadata: JSON.stringify({ userMessageChannel: "vellum", assistantMessageChannel: "vellum", }), }); renderedHistoryContent = { text: "hi", textSegments: ["hi"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; nextDeliveryTs = "1700000300.000800"; await deliverReplyViaCallback( "conv-vellum", "chat-vellum", "http://gateway/deliver/telegram", "assistant-vellum", ); expect(updateMessageMetadataCalls.length).toBe(0); }); it("does NOT call updateMessageMetadata when slackMeta already has channelTs", async () => { // Idempotency: a re-delivery (e.g. from channel-retry-sweep) must not // overwrite a channelTs that is already in place. const existingMeta = JSON.stringify({ source: "slack", eventKind: "message", channelId: "C789", channelTs: "1699999999.000111", }); conversationMessages.push({ id: "msg-already", role: "assistant", content: '[{"type":"text","text":"hi"}]', metadata: JSON.stringify({ userMessageChannel: "slack", assistantMessageChannel: "slack", slackMeta: existingMeta, }), }); renderedHistoryContent = { text: "hi", textSegments: ["hi"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0"], surfaces: [], thinkingSegments: [], }; nextDeliveryTs = "1700000400.000999"; await deliverReplyViaCallback( "conv-already", "C789", "http://gateway/deliver/slack", "assistant-already", ); expect(updateMessageMetadataCalls.length).toBe(0); }); it("only reconciles from the FIRST segment's ts when the reply is split", async () => { pushPartialAssistantRow("conv-multi", "msg-multi", "C999"); // Two-segment delivery: only the first segment's ts is the canonical // channelTs for the persisted row. Subsequent segments correspond to // independent Slack messages. renderedHistoryContent = { text: "AlphaBeta", textSegments: ["Alpha", "Beta"], toolCalls: [], toolCallsBeforeText: false, contentOrder: ["text:0", "tool:0", "text:1"], surfaces: [], thinkingSegments: [], }; nextDeliveryTs = "1700000500.000111"; await deliverReplyViaCallback( "conv-multi", "C999", "http://gateway/deliver/slack", "assistant-multi", ); // Two delivery POSTs but only one metadata write — the first ts wins. expect(deliveryCalls.length).toBe(2); expect(updateMessageMetadataCalls.length).toBe(1); const merged = updateMessageMetadataCalls[0].updates.slackMeta as string; const parsed = JSON.parse(merged) as Record; expect(parsed.channelTs).toBe("1700000500.000111"); }); it("composes with caller-supplied onMessageTs without losing either side-effect", async () => { pushPartialAssistantRow("conv-compose", "msg-compose", "C111"); nextDeliveryTs = "1700000600.000222"; const callerTsSeen: string[] = []; await deliverReplyViaCallback( "conv-compose", "C111", "http://gateway/deliver/slack", "assistant-compose", { onMessageTs: (ts) => callerTsSeen.push(ts), }, ); // Caller's onMessageTs still fires for the delivered segment. expect(callerTsSeen).toEqual(["1700000600.000222"]); // And reconciliation still wrote channelTs. expect(updateMessageMetadataCalls.length).toBe(1); const merged = updateMessageMetadataCalls[0].updates.slackMeta as string; const parsed = JSON.parse(merged) as Record; expect(parsed.channelTs).toBe("1700000600.000222"); }); it("after reconciliation, readSlackMetadata returns a valid envelope", async () => { // End-to-end: this is the assertion that the ORIGINAL gap test // (`outbound-slack-persistence.test.ts:209`) was inverted on. Once // reconciliation runs, readSlackMetadata must accept the merged value. pushPartialAssistantRow("conv-readback", "msg-readback", "C222"); nextDeliveryTs = "1700000700.000333"; await deliverReplyViaCallback( "conv-readback", "C222", "http://gateway/deliver/slack", "assistant-readback", ); expect(updateMessageMetadataCalls.length).toBe(1); const merged = updateMessageMetadataCalls[0].updates.slackMeta as string; // Imported here so the production read path (the same one the renderer // uses) is what actually validates the merged envelope. const { readSlackMetadata } = await import("../messaging/providers/slack/message-metadata.js"); const parsed = readSlackMetadata(merged); expect(parsed).not.toBeNull(); expect(parsed?.channelTs).toBe("1700000700.000333"); expect(parsed?.channelId).toBe("C222"); expect(parsed?.source).toBe("slack"); expect(parsed?.eventKind).toBe("message"); }); }); });