From cc022867be6ab4fa46ae17eba4b683bb9e10aaf6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 21:09:20 -0700 Subject: [PATCH] FN-6505: inline chat attachment contents for agents Chat agents now receive readable attachment content when responding to user prompts. - Add shared attachment loading and formatting for session and room chat surfaces. - Inline supported text attachments and forward image attachments through prompt options. - Cover session and room attachment handling with regression tests and document the behavior. Files changed: .changeset/fuzzy-chat-attachments.md | 5 + docs/dashboard-guide.md | 2 + .../src/__tests__/chat-attachment-content.test.ts | 139 ++++++++++++++++++ .../dashboard/src/__tests__/chat-manager.test.ts | 123 ++++++++++++++++ packages/dashboard/src/chat-attachment-content.ts | 163 +++++++++++++++++++++ packages/dashboard/src/chat.ts | 43 +++++- 6 files changed, 470 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-6505 Fusion-Task-Lineage: 9bc7a65c-2516-42a4-8d3c-2bc53ea1c895 --- .changeset/fuzzy-chat-attachments.md | 5 + docs/dashboard-guide.md | 2 + .../__tests__/chat-attachment-content.test.ts | 139 +++++++++++++++ .../src/__tests__/chat-manager.test.ts | 123 +++++++++++++ .../dashboard/src/chat-attachment-content.ts | 163 ++++++++++++++++++ packages/dashboard/src/chat.ts | 43 ++++- 6 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 .changeset/fuzzy-chat-attachments.md create mode 100644 packages/dashboard/src/__tests__/chat-attachment-content.test.ts create mode 100644 packages/dashboard/src/chat-attachment-content.ts diff --git a/.changeset/fuzzy-chat-attachments.md b/.changeset/fuzzy-chat-attachments.md new file mode 100644 index 0000000000..f6bfd910bc --- /dev/null +++ b/.changeset/fuzzy-chat-attachments.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Inline direct and room chat attachments into agent prompts so agents can read text files and receive supported image attachments. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c476fc4d33..feb04e561a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -240,6 +240,7 @@ Chat view provides project-scoped conversations with agents. - Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. +- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. ![Chat view](./screenshots/chat-view.png) @@ -264,6 +265,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are - If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message. - If room responders cannot be resolved or all room-reply generations fail, the POST now returns an error instead of silently succeeding with only the user message, so failures are surfaced deterministically. - Room responder prompt construction now keeps the most recent room messages verbatim and, when the room runs long, prepends a compacted summary of older history (span, participants, and key highlights) plus an explicit latest-user-message marker so replies stay thread-aware without unbounded prompt growth. +- Room responder prompts include the latest room message attachments using the same direct-chat behavior: text is inlined into the prompt and supported images are forwarded as model image inputs. - On send failure, `useChatRooms` rolls back/reconciles optimistic state and rethrows; `ChatView` catches once, restores the exact pre-send composer text for retry/edit, and surfaces a single error toast (no duplicate hook+view notifications). - After each send attempt, the room transcript still re-fetches authoritative messages so persisted user/assistant replies remain visible even when SSE delivery is delayed, and `chat:room:message:*` SSE updates continue live fan-out. - Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat is still a floating panel, but when a room is selected it now reads/writes that room thread directly. diff --git a/packages/dashboard/src/__tests__/chat-attachment-content.test.ts b/packages/dashboard/src/__tests__/chat-attachment-content.test.ts new file mode 100644 index 0000000000..6b889117c4 --- /dev/null +++ b/packages/dashboard/src/__tests__/chat-attachment-content.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { ChatAttachment } from "@fusion/core"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + CHAT_TEXT_INLINE_LIMIT, + formatChatAttachmentContents, + readChatAttachmentContents, +} from "../chat-attachment-content.js"; + +const roots: string[] = []; + +function attachment(overrides: Partial): ChatAttachment { + return { + id: "att-1", + filename: "note.txt", + originalName: "note.txt", + mimeType: "text/plain", + size: 4, + createdAt: new Date().toISOString(), + ...overrides, + } as ChatAttachment; +} + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "fn-chat-attachment-content-")); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("readChatAttachmentContents", () => { + it("inlines text attachments from the session storage root", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "note.txt"), "hello from attachment"); + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "note.txt", originalName: "note.txt", mimeType: "text/plain" }), + ]); + + expect(result.imageContents).toEqual([]); + expect(result.attachmentContents).toEqual([ + { originalName: "note.txt", mimeType: "text/plain", text: "hello from attachment" }, + ]); + expect(formatChatAttachmentContents(result.attachmentContents)).toContain("hello from attachment"); + }); + + it("converts image attachments to base64 content blocks", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "image.png"), Buffer.from([1, 2, 3, 4])); + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "image.png", originalName: "image.png", mimeType: "image/png", size: 4 }), + ]); + + expect(result.attachmentContents).toEqual([ + { originalName: "image.png", mimeType: "image/png", text: null }, + ]); + expect(result.imageContents).toEqual([ + { type: "image", data: Buffer.from([1, 2, 3, 4]).toString("base64"), mimeType: "image/png" }, + ]); + expect(formatChatAttachmentContents(result.attachmentContents)).toBe(""); + }); + + it("returns mixed text and image contents together", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-room-attachments", "room-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-room-attachments", "room-1", "data.json"), "{\"ok\":true}"); + await writeFile(join(root, ".fusion", "chat-room-attachments", "room-1", "photo.webp"), Buffer.from("webp")); + + const result = await readChatAttachmentContents(root, { kind: "room", roomId: "room-1" }, [ + attachment({ id: "att-text", filename: "data.json", originalName: "data.json", mimeType: "application/json" }), + attachment({ id: "att-image", filename: "photo.webp", originalName: "photo.webp", mimeType: "image/webp" }), + ]); + + expect(formatChatAttachmentContents(result.attachmentContents)).toContain("```json\n{\"ok\":true}\n```"); + expect(result.imageContents).toEqual([ + { type: "image", data: Buffer.from("webp").toString("base64"), mimeType: "image/webp" }, + ]); + }); + + it("skips missing files with a warning", async () => { + const root = await makeRoot(); + const diagnostics = { warn: vi.fn() }; + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "missing.txt", originalName: "missing.txt" }), + ], diagnostics); + + expect(result).toEqual({ attachmentContents: [], imageContents: [] }); + expect(diagnostics.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to read chat attachment 'missing.txt'")); + }); + + it("truncates oversized text attachments at the triage-compatible limit", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "large.txt"), "a".repeat(CHAT_TEXT_INLINE_LIMIT + 10)); + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "large.txt", originalName: "large.txt" }), + ]); + + expect(result.attachmentContents[0]?.text).toHaveLength(CHAT_TEXT_INLINE_LIMIT + "\n... (truncated at 50KB)".length); + expect(result.attachmentContents[0]?.text?.endsWith("\n... (truncated at 50KB)")).toBe(true); + }); + + it("uses basename-safe filenames instead of traversing outside the attachment root", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "safe.txt"), "safe content"); + await writeFile(join(root, ".fusion", "chat-attachments", "outside.txt"), "outside content"); + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "../safe.txt", originalName: "unsafe-name.txt" }), + ]); + + expect(result.attachmentContents[0]?.text).toBe("safe content"); + }); + + it("reads room attachments from the room storage root, not the session root", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "room-1"), { recursive: true }); + await mkdir(join(root, ".fusion", "chat-room-attachments", "room-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "room-1", "note.txt"), "wrong root"); + await writeFile(join(root, ".fusion", "chat-room-attachments", "room-1", "note.txt"), "right room root"); + + const result = await readChatAttachmentContents(root, { kind: "room", roomId: "room-1" }, [ + attachment({ filename: "note.txt", originalName: "note.txt" }), + ]); + + expect(result.attachmentContents[0]?.text).toBe("right room root"); + }); +}); diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index ae93609c84..2240e9e4fb 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -8,6 +8,9 @@ FN-6444 confirmed this ChatManager API-path suite is deterministic under dashboa */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { ChatManager, __setBuildAgentChatPrompt, @@ -82,6 +85,10 @@ function createChatManager(pluginRunner?: Record, messageStore? return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any, undefined, messageStore as any); } +function createChatManagerForRoot(rootDir: string): ChatManager { + return new ChatManager(mockChatStore as any, rootDir, mockAgentStore as any); +} + function createChatManagerWithSettings(settings: { fallbackProvider?: string; fallbackModelId?: string; @@ -1414,6 +1421,55 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.systemPrompt).not.toContain("## Soul"); }); + it("inlines text attachments and forwards image attachments to the chat agent", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fn-chat-agent-attachments-")); + const promptSpy = vi.fn().mockResolvedValue(undefined); + try { + await mkdir(join(rootDir, ".fusion", "chat-attachments", "chat-001"), { recursive: true }); + await writeFile(join(rootDir, ".fusion", "chat-attachments", "chat-001", "note.txt"), "session attachment bytes"); + await writeFile(join(rootDir, ".fusion", "chat-attachments", "chat-001", "image.png"), Buffer.from([9, 8, 7])); + + __setCreateFnAgent(async () => ({ + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Done" }] }, + }, + })); + + const chatManager = createChatManagerForRoot(rootDir); + await chatManager.sendMessage("chat-001", "What is attached?", undefined, undefined, [ + { + id: "att-text", + filename: "note.txt", + originalName: "note.txt", + mimeType: "text/plain", + size: 24, + createdAt: "2026-06-16T00:00:00.000Z", + }, + { + id: "att-image", + filename: "image.png", + originalName: "image.png", + mimeType: "image/png", + size: 3, + createdAt: "2026-06-16T00:00:00.000Z", + }, + ]); + + expect(promptSpy).toHaveBeenCalledTimes(1); + const [promptArgument, promptOptions] = promptSpy.mock.calls[0] ?? []; + expect(promptArgument).toContain("[User attached: note.txt (text/plain, 24B), image.png (image/png, 3B)]"); + expect(promptArgument).toContain("## Attachments"); + expect(promptArgument).toContain("session attachment bytes"); + expect(promptOptions).toEqual({ + images: [{ type: "image", data: Buffer.from([9, 8, 7]).toString("base64"), mimeType: "image/png" }], + }); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + it("sends only the new user message — prior turns come from the resumed CLI session, not the prompt", async () => { const promptSpy = vi.fn().mockResolvedValue(undefined); @@ -2180,6 +2236,73 @@ describe("ChatManager generation isolation", () => { expect(chatManager.isGenerating("chat-001")).toBe(false); }); + it("sendRoomMessage inlines room text attachments and forwards room image attachments", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fn-chat-room-agent-attachments-")); + const promptSpy = vi.fn().mockResolvedValue(undefined); + try { + await mkdir(join(rootDir, ".fusion", "chat-room-attachments", "room-1"), { recursive: true }); + await writeFile(join(rootDir, ".fusion", "chat-room-attachments", "room-1", "room-note.txt"), "room attachment bytes"); + await writeFile(join(rootDir, ".fusion", "chat-room-attachments", "room-1", "room-image.webp"), Buffer.from([5, 4, 3])); + + (mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" }); + (mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([ + { roomId: "room-1", agentId: "agent-001", role: "member", addedAt: "2026-01-01" }, + ]); + (mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({ + id: input.role === "user" ? "user-room-msg" : "assistant-room-msg", + roomId: "room-1", + ...input, + })); + + mockAgentStore.listAgents.mockResolvedValue([ + { id: "agent-001", name: "Avery", role: "executor", state: "idle" }, + ]); + mockAgentStore.getAgent.mockResolvedValue({ id: "agent-001", name: "Avery", role: "executor", state: "idle" }); + + __setCreateResolvedAgentSession(async () => ({ + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Room answer" }] }, + }, + provider: "test", + model: "test", + fallbackInfo: undefined, + } as any)); + + const chatManager = createChatManagerForRoot(rootDir); + await chatManager.sendRoomMessage("room-1", "hello @Avery", [ + { + id: "att-room-text", + filename: "room-note.txt", + originalName: "room-note.txt", + mimeType: "text/plain", + size: 21, + createdAt: "2026-06-16T00:00:00.000Z", + }, + { + id: "att-room-image", + filename: "room-image.webp", + originalName: "room-image.webp", + mimeType: "image/webp", + size: 3, + createdAt: "2026-06-16T00:00:00.000Z", + }, + ]); + + expect(promptSpy).toHaveBeenCalledTimes(1); + const [promptArgument, promptOptions] = promptSpy.mock.calls[0] ?? []; + expect(promptArgument).toContain("Latest user message to answer:\n\nhello @Avery"); + expect(promptArgument).toContain("## Attachments"); + expect(promptArgument).toContain("room attachment bytes"); + expect(promptOptions).toEqual({ + images: [{ type: "image", data: Buffer.from([5, 4, 3]).toString("base64"), mimeType: "image/webp" }], + }); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + it("sendRoomMessage persists assistant room replies", async () => { (mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" }); (mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([ diff --git a/packages/dashboard/src/chat-attachment-content.ts b/packages/dashboard/src/chat-attachment-content.ts new file mode 100644 index 0000000000..027c0e0da2 --- /dev/null +++ b/packages/dashboard/src/chat-attachment-content.ts @@ -0,0 +1,163 @@ +import type { ChatAttachment } from "@fusion/core"; +import { readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { CHAT_ALLOWED_MIME_TYPES } from "./routes/chat-attachment-config.js"; + +export interface ChatImageContent { + type: "image"; + data: string; + mimeType: string; +} + +export interface ChatAttachmentContent { + originalName: string; + mimeType: string; + text: string | null; +} + +export type ChatAttachmentScope = + | { kind: "session"; sessionId: string } + | { kind: "room"; roomId: string }; + +export interface ChatAttachmentDiagnostics { + warn(message: string, ...args: unknown[]): void; +} + +export interface ReadChatAttachmentContentsResult { + attachmentContents: ChatAttachmentContent[]; + imageContents: ChatImageContent[]; +} + +const IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); + +const TEXT_MIME_TYPES = new Set( + [...CHAT_ALLOWED_MIME_TYPES].filter((mimeType) => !IMAGE_MIME_TYPES.has(mimeType)), +); + +export const CHAT_TEXT_INLINE_LIMIT = 50 * 1024; +const TRUNCATION_SUFFIX = "\n... (truncated at 50KB)"; + +function getAttachmentDirectory(rootDir: string, scope: ChatAttachmentScope): string { + if (scope.kind === "session") { + return resolve(rootDir, ".fusion", "chat-attachments", scope.sessionId); + } + + return resolve(rootDir, ".fusion", "chat-room-attachments", scope.roomId); +} + +function getScopeLabel(scope: ChatAttachmentScope): string { + return scope.kind === "session" ? `session ${scope.sessionId}` : `room ${scope.roomId}`; +} + +function fenceLanguageForMimeType(mimeType: string): string { + switch (mimeType) { + case "application/json": + return "json"; + case "text/yaml": + return "yaml"; + case "text/x-toml": + return "toml"; + case "text/csv": + return "csv"; + case "application/xml": + return "xml"; + default: + return "text"; + } +} + +function escapeFence(text: string): string { + return text.replaceAll("```", "``\\`"); +} + +/** + * FNXC:ChatAttachments 2026-06-16-19:55: + * Dashboard chat agents must receive real user-attached bytes, not only attachment names. Session chat reads from .fusion/chat-attachments/{sessionId}; room chat reads from .fusion/chat-room-attachments/{roomId}; basename resolution prevents uploaded filenames from escaping those per-surface roots. + * + * FNXC:ChatAttachments 2026-06-16-19:55: + * Text attachments are prompt-inlined with the triage-compatible 50KB ceiling while image attachments are forwarded as pi image content blocks through promptWithFallback options. + */ +export async function readChatAttachmentContents( + rootDir: string, + scope: ChatAttachmentScope, + attachments?: ChatAttachment[], + diagnostics?: ChatAttachmentDiagnostics, +): Promise { + const attachmentContents: ChatAttachmentContent[] = []; + const imageContents: ChatImageContent[] = []; + + if (!attachments || attachments.length === 0) { + return { attachmentContents, imageContents }; + } + + const attachmentDir = getAttachmentDirectory(rootDir, scope); + + for (const attachment of attachments) { + if (!CHAT_ALLOWED_MIME_TYPES.has(attachment.mimeType)) { + diagnostics?.warn(`Skipping unsupported chat attachment '${attachment.filename}' (${attachment.mimeType}) for ${getScopeLabel(scope)}`); + continue; + } + + const safeName = basename(attachment.filename); + const filePath = resolve(attachmentDir, safeName); + + try { + if (IMAGE_MIME_TYPES.has(attachment.mimeType)) { + const data = await readFile(filePath); + imageContents.push({ + type: "image", + data: data.toString("base64"), + mimeType: attachment.mimeType, + }); + attachmentContents.push({ + originalName: attachment.originalName, + mimeType: attachment.mimeType, + text: null, + }); + continue; + } + + if (!TEXT_MIME_TYPES.has(attachment.mimeType)) { + diagnostics?.warn(`Skipping non-inlineable chat attachment '${attachment.filename}' (${attachment.mimeType}) for ${getScopeLabel(scope)}`); + continue; + } + + const data = await readFile(filePath, "utf-8"); + const text = data.length > CHAT_TEXT_INLINE_LIMIT + ? `${data.slice(0, CHAT_TEXT_INLINE_LIMIT)}${TRUNCATION_SUFFIX}` + : data; + attachmentContents.push({ + originalName: attachment.originalName, + mimeType: attachment.mimeType, + text, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics?.warn(`Failed to read chat attachment '${attachment.filename}' for ${getScopeLabel(scope)}, skipping: ${message}`); + } + } + + return { attachmentContents, imageContents }; +} + +export function formatChatAttachmentContents(attachmentContents: ChatAttachmentContent[]): string { + const inlineAttachments = attachmentContents.filter((attachment) => attachment.text !== null); + if (inlineAttachments.length === 0) { + return ""; + } + + return [ + "## Attachments", + ...inlineAttachments.map((attachment) => [ + `### ${attachment.originalName} (${attachment.mimeType})`, + `\`\`\`${fenceLanguageForMimeType(attachment.mimeType)}`, + escapeFence(attachment.text ?? ""), + "```", + ].join("\n")), + ].join("\n\n"); +} diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index e496925993..3dc2bc215b 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -32,6 +32,7 @@ import { existsSync } from "node:fs"; import { join, resolve, relative } from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { SessionEventBuffer } from "./sse-buffer.js"; +import { formatChatAttachmentContents, readChatAttachmentContents } from "./chat-attachment-content.js"; import { createFnAgent as engineCreateFnAgent, @@ -1215,6 +1216,7 @@ export class ChatManager { roomName: room.name, content: trimmedContent, latestUserMessageId: userMessage.id, + attachments, mentions, responder, modelProvider, @@ -1271,6 +1273,7 @@ export class ChatManager { roomName: string; content: string; latestUserMessageId: string; + attachments?: ChatAttachment[]; mentions: ChatMention[]; responder: Agent; modelProvider?: string; @@ -1301,7 +1304,14 @@ export class ChatManager { const roomCompactionSettings = await this.getRoomCompactionSettings(); const roomMessages = this.chatStore.getRoomMessages(input.roomId, { limit: roomCompactionSettings.fetchLimit }); - const roomPrompt = [ + const { attachmentContents, imageContents } = await readChatAttachmentContents( + this.rootDir, + { kind: "room", roomId: input.roomId }, + input.attachments, + diagnostics, + ); + const attachmentContentBlock = formatChatAttachmentContents(attachmentContents); + const roomPromptParts = [ `You are replying as ${input.responder.name} in room #${input.roomName}.`, "Reply to the latest user room message in the context of this shared room thread.", "Room transcript (oldest to newest, bounded):", @@ -1311,7 +1321,11 @@ export class ChatManager { }), "Latest user message to answer:", input.content, - ].join("\n\n"); + ]; + if (attachmentContentBlock) { + roomPromptParts.push(attachmentContentBlock); + } + const roomPrompt = roomPromptParts.join("\n\n"); const responderRuntimeModel = extractRuntimeModel(input.responder.runtimeConfig); const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider; @@ -1354,7 +1368,11 @@ export class ChatManager { }); try { - await enginePromptWithFallback(resolvedSession.session, roomPrompt); + await enginePromptWithFallback( + resolvedSession.session, + roomPrompt, + imageContents.length > 0 ? { images: imageContents } : undefined, + ); type AgentMessage = { role?: string; type?: string; content?: string | Array<{ type?: string; text?: string }> }; const messages = (resolvedSession.session.state.messages as AgentMessage[]) ?? []; @@ -1449,6 +1467,10 @@ export class ChatManager { // CLI-agent-backed chat: a session that selected a cli-agent executor brokers // its composer sends to the live PTY (via the runner) rather than running the // model agent loop. The runner persists the user message + the transcript. + /* + FNXC:ChatAttachments 2026-06-16-20:00: + Attachment content inlining is intentionally limited to model-loop chat sessions. CLI-agent-backed chat sends to a live PTY, so changing it here would alter terminal input semantics instead of using promptWithFallback image/text options. + */ if (session?.cliExecutorAdapterId && this.cliChatRunner) { const runner = this.cliChatRunner; try { @@ -1647,12 +1669,19 @@ export class ChatManager { .map((attachment) => `${attachment.originalName} (${attachment.mimeType}, ${formatAttachmentSize(attachment.size)})`) .join(", ")}]` : ""; + const { attachmentContents, imageContents } = await readChatAttachmentContents( + this.rootDir, + { kind: "session", sessionId }, + attachments, + diagnostics, + ); + const attachmentContentBlock = formatChatAttachmentContents(attachmentContents); // Send only the new user content. Prior turns are reloaded by the // pi/Claude CLI session via SessionManager.open() below — stuffing the // transcript back into the user message would balloon the on-disk // session every turn (and previously did, see chat-store.ts:setCliSessionFile). - const promptContent = [attachmentSummary, resolvedContent].filter(Boolean).join("\n\n"); + const promptContent = [attachmentSummary, attachmentContentBlock, resolvedContent].filter(Boolean).join("\n\n"); // Per-chat session continuity: the pi SessionManager (and, transitively, // the Claude CLI --resume session it owns) is keyed off the chat. On the @@ -1797,7 +1826,11 @@ export class ChatManager { } // Send user message and get response - await enginePromptWithFallback(agentResult.session, promptContent); + await enginePromptWithFallback( + agentResult.session, + promptContent, + imageContents.length > 0 ? { images: imageContents } : undefined, + ); if (abortController.signal.aborted) { return;