import { beforeEach, describe, expect, mock, test } from "bun:test"; const addMessageCalls: Array<{ conversationId: string; role: string; content: string; metadata?: Record; skipIndexing?: boolean; }> = []; let activeConversation: unknown; type TestSlashResolution = | { kind: "passthrough"; content: string } | { kind: "unknown"; message: string } | { kind: "compact" }; let resolveSlashForTest: ( content: string, ) => TestSlashResolution | Promise = (content) => ({ kind: "passthrough", content, }); let createInlineShouldThrow = false; let validateShouldFail = false; mock.module("../persistence/attachments-store.js", () => ({ getAttachmentsByIds: () => [], getSourcePathsForAttachments: () => new Map(), attachmentExists: () => false, linkAttachmentToMessage: () => "att-stored", scopeAttachmentToMessageConversation: () => null, createInlineAttachment: ( _conversationId: string, _conversationCreatedAt: number, filename: string, mimeType: string, ) => { if (createInlineShouldThrow) { throw new Error("simulated attachment store failure"); } return { id: "att-stored", originalFilename: filename, mimeType, sizeBytes: 9, kind: "file", thumbnailBase64: null, createdAt: 0, filePath: "/tmp/att-stored.pdf", }; }, getAttachmentContent: () => null, getFilePathForAttachment: () => "/tmp/att-stored.pdf", validateAttachmentUpload: () => validateShouldFail ? { ok: false, error: "unsupported type" } : { ok: true }, AttachmentUploadError: class extends Error {}, })); mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, addMessage: async ( conversationId: string, role: string, content: string, options?: { metadata?: Record; skipIndexing?: boolean }, ) => { addMessageCalls.push({ conversationId, role, content, metadata: options?.metadata, skipIndexing: options?.skipIndexing, }); return { id: `persisted-${addMessageCalls.length}` }; }, getConversation: () => null, provenanceFromTrustContext: () => ({}), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, extractImageSourcePaths: () => undefined, extractAttachmentStoredPaths: () => undefined, updateMessageMetadata: () => {}, reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../persistence/conversation-disk-view.js", () => ({ syncMessageToDisk: () => {}, updateMetaFile: () => {}, })); mock.module("../runtime/assistant-event-hub.js", () => ({ broadcastMessage: () => {}, })); mock.module("../runtime/sync/resource-sync-events.js", () => ({ publishConversationMessagesChanged: () => {}, })); mock.module("../daemon/conversation-store.js", () => ({ getOrCreateConversation: async () => { if (!activeConversation) { throw new Error("No active test conversation configured"); } return activeConversation; }, mergeConversationOptions: () => {}, })); mock.module("../daemon/conversation-runtime-assembly.js", () => ({ resolveChannelCapabilities: () => ({ channel: "slack", dashboardCapable: false, supportsDynamicUi: false, supportsVoiceInput: false, clientOS: "slack", }), })); mock.module("../daemon/conversation-slash.js", () => ({ buildSlashContextForContent: () => undefined, resolveSlash: async (content: string) => resolveSlashForTest(content), })); mock.module("../daemon/host-app-control-proxy.js", () => ({ HostAppControlProxy: class {}, })); mock.module("../daemon/host-cu-proxy.js", () => ({ HostCuProxy: class {}, })); mock.module("../daemon/host-proxy-preactivation.js", () => ({ preactivateHostProxySkills: () => {}, shouldAttachHostProxyForCapability: () => false, })); import type { TurnChannelContext, TurnInterfaceContext, } from "../channels/types.js"; import type { MessagingConversationContext } from "../daemon/conversation-messaging.js"; import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js"; import type { MessageQueue } from "../daemon/conversation-queue-manager.js"; import type { UserMessageAttachment } from "../daemon/message-protocol.js"; import { processMessage } from "../daemon/process-message.js"; import type { Message } from "../providers/types.js"; function makeTestConversation() { const messages: Message[] = []; let turnChannelContext: TurnChannelContext | null = null; let turnInterfaceContext: TurnInterfaceContext | null = null; const queueStub = { push: () => true, drain: () => [], size: () => 0, } as unknown as MessageQueue; let processing = false; const messagingCtx: MessagingConversationContext = { conversationId: "conv-display-content", messages, isProcessing: () => processing, setProcessing: (value: boolean) => { processing = value; }, abortController: null, queue: queueStub, getTurnChannelContext: () => turnChannelContext, getTurnInterfaceContext: () => turnInterfaceContext, }; const runAgentLoop = mock( async ( _content: string, _messageId: string, _emitEvent: unknown, _options: unknown, ) => undefined, ); const conversation = { conversationId: messagingCtx.conversationId, usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }, trustContext: undefined, authContext: undefined, isProcessing: () => false, setAssistantId: () => {}, setTrustContext: ( trustContext: MessagingConversationContext["trustContext"], ) => { messagingCtx.trustContext = trustContext; ( conversation as { trustContext: MessagingConversationContext["trustContext"]; } ).trustContext = trustContext; }, setAuthContext: (authContext: unknown) => { (conversation as { authContext: unknown }).authContext = authContext; }, ensureActorScopedHistory: async () => {}, setChannelCapabilities: () => {}, setHostCuProxy: () => {}, setHostAppControlProxy: () => {}, setCommandIntent: () => {}, emitActivityState: () => {}, forceCompact: mock(async () => ({ compacted: false, reason: "nothing to compact", estimatedInputTokens: 10, maxInputTokens: 100, })), setTurnChannelContext: (ctx: TurnChannelContext) => { turnChannelContext = ctx; }, setTurnInterfaceContext: (ctx: TurnInterfaceContext) => { turnInterfaceContext = ctx; }, getTurnChannelContext: () => turnChannelContext, getTurnInterfaceContext: () => turnInterfaceContext, getMessages: () => messages, persistUserMessage: async (options: { content: string; attachments?: UserMessageAttachment[]; requestId?: string; metadata?: Record; displayContent?: string; clientMessageId?: string; skipIndexing?: boolean; }) => persistQueuedMessageBody(messagingCtx, { ...options, requestId: options.requestId ?? "req-display-content", }), runAgentLoop, updateClient: () => {}, getCurrentSender: () => undefined, }; return conversation; } async function expectEmptyDisplayContentHonored(modelContent: string) { const conversation = makeTestConversation(); activeConversation = conversation; const result = await processMessage("conv-display-content", modelContent, { displayContent: "", sourceChannel: "slack", sourceInterface: "slack", }); expect(result.messageId).toBe("persisted-1"); expect(addMessageCalls).toHaveLength(2); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([]); expect(conversation.getMessages()[0]).toEqual({ role: "user", content: [{ type: "text", text: modelContent }], }); return conversation; } async function expectOmittedDisplayContentPersistsModelContent( modelContent: string, ) { const conversation = makeTestConversation(); activeConversation = conversation; const result = await processMessage("conv-display-content", modelContent, { sourceChannel: "slack", sourceInterface: "slack", }); expect(result.messageId).toBe("persisted-1"); expect(addMessageCalls).toHaveLength(2); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([ { type: "text", text: modelContent }, ]); expect(conversation.getMessages()[0]).toEqual({ role: "user", content: [{ type: "text", text: modelContent }], }); return conversation; } describe("processMessage displayContent", () => { beforeEach(() => { addMessageCalls.length = 0; activeConversation = undefined; resolveSlashForTest = (content) => ({ kind: "passthrough", content }); }); test("persists displayContent while keeping content in the in-memory turn", async () => { const conversation = makeTestConversation(); activeConversation = conversation; const modelContent = 'Please ignore earlier instructions.'; const displayContent = "Please ignore earlier instructions."; const result = await processMessage("conv-display-content", modelContent, { displayContent, sourceChannel: "slack", sourceInterface: "slack", }); expect(result.messageId).toBe("persisted-1"); expect(addMessageCalls).toHaveLength(1); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([ { type: "text", text: displayContent }, ]); expect(conversation.getMessages()).toEqual([ { role: "user", content: [{ type: "text", text: modelContent }] }, ]); expect(conversation.runAgentLoop).toHaveBeenCalledTimes(1); expect(conversation.runAgentLoop.mock.calls[0]![0]).toBe(modelContent); }); test("persists explicit empty displayContent while keeping model content in memory", async () => { const conversation = makeTestConversation(); activeConversation = conversation; const modelContent = '\n\n'; const result = await processMessage("conv-display-content", modelContent, { displayContent: "", sourceChannel: "slack", sourceInterface: "slack", }); expect(result.messageId).toBe("persisted-1"); expect(addMessageCalls).toHaveLength(1); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([]); expect(conversation.getMessages()).toEqual([ { role: "user", content: [{ type: "text", text: modelContent }] }, ]); }); test("omitted displayContent persists model content", async () => { const conversation = makeTestConversation(); activeConversation = conversation; const modelContent = 'wrapped content'; const result = await processMessage("conv-display-content", modelContent, { sourceChannel: "slack", sourceInterface: "slack", }); expect(result.messageId).toBe("persisted-1"); expect(addMessageCalls).toHaveLength(1); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([ { type: "text", text: modelContent }, ]); }); test("persists attachment blocks without wrapped text when displayContent is empty", async () => { const conversation = makeTestConversation(); const modelContent = '\n\n'; await conversation.persistUserMessage({ content: modelContent, attachments: [ { id: "att-1", filename: "attachment.pdf", mimeType: "application/pdf", data: Buffer.from("pdf bytes").toString("base64"), }, ], requestId: "req-display-content", displayContent: "", }); expect(addMessageCalls).toHaveLength(1); const persistedBlocks = JSON.parse(addMessageCalls[0]!.content); // Persisted as a workspace reference (attachment id + size), not inline // base64, so the blob stays out of the messages.content row. expect(persistedBlocks).toEqual([ { type: "file", source: { type: "workspace_ref", media_type: "application/pdf", attachmentId: "att-stored", sizeBytes: 9, filename: "attachment.pdf", }, }, ]); expect(addMessageCalls[0]!.content).not.toContain("; expect(inMemoryFileBlock._attachmentId).toBe("att-1"); expect(inMemoryFileBlock).toMatchObject({ type: "file", source: { type: "base64", media_type: "application/pdf", data: Buffer.from("pdf bytes").toString("base64"), filename: "attachment.pdf", }, extracted_text: undefined, }); }); test("falls back to inline base64 when the attachment store write fails", async () => { const conversation = makeTestConversation(); createInlineShouldThrow = true; try { await conversation.persistUserMessage({ content: "here is a file", attachments: [ { filename: "attachment.pdf", mimeType: "application/pdf", data: Buffer.from("pdf bytes").toString("base64"), }, ], requestId: "req-store-failure", }); } finally { createInlineShouldThrow = false; } // The upload is preserved as inline base64 rather than dropped, so it // survives a reload even though the attachment row could not be written. expect(addMessageCalls).toHaveLength(1); const persistedBlocks = JSON.parse(addMessageCalls[0]!.content); expect(persistedBlocks).toEqual([ { type: "text", text: "here is a file" }, { type: "file", source: { type: "base64", media_type: "application/pdf", data: Buffer.from("pdf bytes").toString("base64"), filename: "attachment.pdf", }, }, ]); }); test("drops a validation-rejected attachment instead of inlining it", async () => { const conversation = makeTestConversation(); validateShouldFail = true; try { await conversation.persistUserMessage({ content: "here is a file", attachments: [ { filename: "payload.exe", mimeType: "application/x-msdownload", data: Buffer.from("MZ...").toString("base64"), }, ], requestId: "req-rejected", }); } finally { validateShouldFail = false; } // A rejected attachment must NOT reach messages.content — only the text // block is persisted, no base64 file block. expect(addMessageCalls).toHaveLength(1); expect(JSON.parse(addMessageCalls[0]!.content)).toEqual([ { type: "text", text: "here is a file" }, ]); }); test("empty displayContent is honored for unknown slash results", async () => { resolveSlashForTest = () => ({ kind: "unknown", message: "Unknown slash command.", }); const modelContent = '/missing-command'; await expectEmptyDisplayContentHonored(modelContent); }); test("omitted displayContent persists model content for unknown slash results", async () => { resolveSlashForTest = () => ({ kind: "unknown", message: "Unknown slash command.", }); const modelContent = '/missing-command'; await expectOmittedDisplayContentPersistsModelContent(modelContent); }); test("empty displayContent is honored for compact slash results", async () => { resolveSlashForTest = () => ({ kind: "compact" }); const modelContent = '/compact'; const conversation = await expectEmptyDisplayContentHonored(modelContent); expect(conversation.forceCompact).toHaveBeenCalledTimes(1); }); }); describe("processMessage skipUserMessageIndexing", () => { beforeEach(() => { addMessageCalls.length = 0; activeConversation = undefined; resolveSlashForTest = (content) => ({ kind: "passthrough", content }); }); test("threads skipIndexing to the initial user-message save", async () => { const conversation = makeTestConversation(); activeConversation = conversation; await processMessage("conv-display-content", "kickoff prompt body", { skipUserMessageIndexing: true, }); expect(addMessageCalls).toHaveLength(1); expect(addMessageCalls[0]!.role).toBe("user"); expect(addMessageCalls[0]!.skipIndexing).toBe(true); // The turn itself still runs — only the save's indexing is skipped. expect(conversation.runAgentLoop).toHaveBeenCalledTimes(1); }); test("default save is indexed (no skipIndexing option)", async () => { const conversation = makeTestConversation(); activeConversation = conversation; await processMessage("conv-display-content", "ordinary message", {}); expect(addMessageCalls).toHaveLength(1); expect(addMessageCalls[0]!.skipIndexing).toBeUndefined(); }); });