diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index a9e2de62a..bb7dcc6f2 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -15,6 +15,7 @@ import { } from "./TranscriptActivityGroup"; import { TranscriptToolView } from "./TranscriptToolView"; import { TranscriptReasoningView } from "./TranscriptReasoningView"; +import { TranscriptAttachmentsDeliveredView } from "./TranscriptAttachmentsDeliveredView"; import { TranscriptStructuredEventView } from "./TranscriptStructuredEventView"; import { TranscriptFailureView } from "./TranscriptFailureView"; import { @@ -40,6 +41,10 @@ type TranscriptEntry = ReturnType[number]; type TranscriptContextEntry = Extract; type TranscriptFailureEntry = Extract; type TranscriptMessageEntry = Extract; +type TranscriptAttachmentsDeliveredEntry = Extract< + TranscriptEntry, + { kind: "attachments_delivered" } +>; type TranscriptStructuredEventEntry = Extract< TranscriptEntry, { kind: "structured_event" } @@ -171,6 +176,15 @@ function VisibleTranscriptEntries(props: { /> ) } + renderAttachmentsDelivered={(entry) => ( + + + + )} renderStructuredEvent={(entry) => ( ReactNode; renderContext: (entry: TranscriptContextEntry) => ReactNode; renderFailure: (entry: TranscriptFailureEntry) => ReactNode; renderMessage: (entry: TranscriptMessageEntry) => ReactNode; @@ -227,6 +244,9 @@ function TranscriptEntryList(props: { const renderEntry = (entry: TranscriptEntry): ReactNode => { if (entry.kind === "subagent") return props.renderSubagent(entry); if (entry.kind === "context") return props.renderContext(entry); + if (entry.kind === "attachments_delivered") { + return props.renderAttachmentsDelivered(entry); + } if (entry.kind === "structured_event") { return props.renderStructuredEvent(entry); } @@ -359,6 +379,15 @@ function RedactedTranscriptView(props: { /> ) } + renderAttachmentsDelivered={(entry) => ( + + + + )} renderStructuredEvent={(entry) => ( entry.kind === "structured_event", ).length; + const attachmentsCount = entries.filter( + (entry) => entry.kind === "attachments_delivered", + ).length; const resourceEventCount = entries.filter( (entry) => entry.kind === "message", ).length; @@ -114,6 +117,9 @@ export function activityGroupSummary( structuredCount > 0 ? countLabel(structuredCount, "1 structured event", "structured events") : undefined, + attachmentsCount > 0 + ? countLabel(attachmentsCount, "1 file delivery", "file deliveries") + : undefined, resourceEventCount > 0 ? countLabel(resourceEventCount, "1 resource event", "resource events") : undefined, diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx new file mode 100644 index 000000000..ed3e962aa --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx @@ -0,0 +1,126 @@ +import { Download, FileText, Image as ImageIcon } from "lucide-react"; + +import { formatMessageTimestamp } from "../format"; +import type { + ConversationTranscript, + TranscriptViewAttachmentsDeliveredPart, + TranscriptViewDeliveredAttachment, +} from "../types"; +import { HighlightText, useTranscriptSearch } from "./transcriptSearch"; + +function mayDisplayInline(contentType: string): boolean { + return ( + contentType === "image/gif" || + contentType === "image/jpeg" || + contentType === "image/png" || + contentType === "image/webp" + ); +} + +function attachmentUrl( + conversationId: string, + attachmentId: string, +): string { + return `/api/conversations/${encodeURIComponent(conversationId)}/attachments/${encodeURIComponent(attachmentId)}`; +} + +function formatAttachmentBytes(bytes: number | undefined): string | undefined { + if (bytes === undefined) return undefined; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function AttachmentCard(props: { + attachment: TranscriptViewDeliveredAttachment; + conversationId: string; +}) { + const search = useTranscriptSearch(); + const href = attachmentUrl(props.conversationId, props.attachment.id); + const size = formatAttachmentBytes(props.attachment.bytes); + const inline = mayDisplayInline(props.attachment.contentType); + + return ( +
+ {inline && !search.active ? ( + + {props.attachment.name} + + ) : null} +
+ +
+
+ +
+
+ value !== undefined) + .join(" ยท ")} + /> +
+
+ + + download + +
+
+ ); +} + +/** Render host-delivered conversation attachments as first-class transcript media. */ +export function TranscriptAttachmentsDeliveredView(props: { + conversation: ConversationTranscript; + part: TranscriptViewAttachmentsDeliveredPart; + timestamp?: number; +}) { + const timestamp = formatMessageTimestamp(props.timestamp); + const count = props.part.attachments.length; + const title = count === 1 ? "1 file delivered" : `${count} files delivered`; + + return ( +
+
+
+ +
+ {timestamp ? ( + + {timestamp} + + ) : null} +
+
+ {props.part.attachments.map((attachment) => ( + + ))} +
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx index 9faaf3fd6..53a08994f 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx @@ -12,6 +12,7 @@ import { Link, MessageSquareText, Minimize2, + Paperclip, Send, Sparkles, TriangleAlert, @@ -22,6 +23,7 @@ import { cn } from "../styles"; import type { TranscriptViewStructuredEventPart } from "../types"; type TranscriptRailEventKind = + | "attachments_delivered" | "compaction" | "handoff" | "message_context" @@ -73,6 +75,12 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): { icon: Diff, }; } + if (kind === "attachments_delivered") { + return { + className: "text-sky-200", + icon: Paperclip, + }; + } if (kind === "structured_event") { return { className: "text-violet-200", diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 27b5df8b5..0c6e55ec4 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -358,6 +358,19 @@ export function conversationTranscriptMessages( continue; } + if (data.type === "attachments_delivered") { + messages.push( + eventMessage(event, "system", [ + { + type: "attachments_delivered", + attachments: data.attachments, + ...(data.toolCallId ? { toolCallId: data.toolCallId } : {}), + }, + ]), + ); + continue; + } + if (data.type === "compaction" || data.type === "handoff") { messages.push( eventMessage(event, "system", [ diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 21c5b6ee2..52572b155 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -389,6 +389,16 @@ function transcriptPartVersion(part: TranscriptViewPart | undefined): string { part.presentation.details?.length ?? 0, ].join(":"); } + if (part.type === "attachments_delivered") { + return [ + part.type, + part.toolCallId ?? "", + ...part.attachments.map( + (attachment) => + `${attachment.id}:${attachment.name}:${attachment.contentType}:${attachment.bytes ?? ""}`, + ), + ].join(":"); + } return [part.type, part.event.type, part.event.createdAt].join(":"); } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index 9d6c0ece4..7c1240600 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -1,4 +1,5 @@ import type { + TranscriptViewAttachmentsDeliveredPart, TranscriptViewContextEventPart, TranscriptViewMessage, TranscriptViewStructuredEventPart, @@ -15,6 +16,13 @@ export type RenderedFailureEntry = { timestamp?: number; }; +export type RenderedAttachmentsDeliveredEntry = { + key: string; + kind: "attachments_delivered"; + part: TranscriptViewAttachmentsDeliveredPart; + timestamp?: number; +}; + export type RenderedContextEventEntry = { key: string; kind: "context"; @@ -59,6 +67,7 @@ export type RenderedMessageEntry = { }; export type RenderedTranscriptEntry = + | RenderedAttachmentsDeliveredEntry | RenderedContextEventEntry | RenderedFailureEntry | RenderedMessageEntry @@ -124,6 +133,13 @@ export function groupTranscriptMessages( part, timestamp: message.timestamp, }); + } else if (part.type === "attachments_delivered") { + entries.push({ + key: `${message.sourceSeq}:attachments-delivered`, + kind: "attachments_delivered", + part, + timestamp: message.timestamp, + }); } else { entries.push({ key: `${message.sourceSeq}:context:${partIndex}`, @@ -172,6 +188,11 @@ export function messageRawText(message: TranscriptViewMessage): string { .filter((line): line is string => line !== undefined) .join("\n"); } + if (part.type === "attachments_delivered") { + return part.attachments + .map((attachment) => attachment.name) + .join("\n"); + } if (part.event.type !== "handoff") { return ["context compacted", part.event.summary] .filter((line): line is string => line !== undefined) diff --git a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx index ca018ce45..e647c099b 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx +++ b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx @@ -175,6 +175,14 @@ export function entryMatchesSearch( ].some((value) => textContains(value, normalizedQuery)); } + if (entry.kind === "attachments_delivered") { + return entry.part.attachments.some( + (attachment) => + textContains(attachment.name, normalizedQuery) || + textContains(attachment.contentType, normalizedQuery), + ); + } + if (entry.kind === "context") { const event = entry.part.event; return event.type === "handoff" diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index 73dbf7241..291602569 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -179,6 +179,22 @@ function appendTranscriptMessages( continue; } + if (entry.kind === "attachments_delivered") { + const count = entry.part.attachments.length; + lines.push( + "", + `### ${count === 1 ? "1 file delivered" : `${count} files delivered`}`, + ); + addEventMeta(lines, conversationTranscript, entry.timestamp); + for (const attachment of entry.part.attachments) { + lines.push( + "", + `- ${attachment.name} (${attachment.contentType}${attachment.bytes !== undefined ? `, ${attachment.bytes} bytes` : ""})`, + ); + } + continue; + } + appendTool(lines, conversationTranscript, entry.part, entry.timestamp); } } diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index dfe715b1a..83ae3ad62 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -78,7 +78,21 @@ export type TranscriptViewStructuredEventPart = { version: number; }; +export type TranscriptViewDeliveredAttachment = { + bytes?: number; + contentType: string; + id: string; + name: string; +}; + +export type TranscriptViewAttachmentsDeliveredPart = { + attachments: TranscriptViewDeliveredAttachment[]; + toolCallId?: string; + type: "attachments_delivered"; +}; + export type TranscriptViewPart = + | TranscriptViewAttachmentsDeliveredPart | TranscriptViewContextEventPart | TranscriptViewStructuredEventPart | TranscriptViewReasoningPart diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index bb3c886da..aacfa4c36 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -672,6 +672,44 @@ describe("dashboard canonical-event components", () => { expect(html).toContain("2 memories captured"); }); + it("renders delivered attachments as transcript media, not tool chrome", () => { + const html = renderTranscript( + conversation([ + event(0, { + type: "attachments_delivered", + toolCallId: "call-send-1", + attachments: [ + { + id: "att-1", + name: "chart.png", + contentType: "image/png", + bytes: 18211, + }, + { + id: "att-2", + name: "notes.txt", + contentType: "text/plain", + bytes: 42, + }, + ], + }), + ]), + ); + + expect(html).toContain( + 'data-transcript-rail-event="attachments_delivered"', + ); + expect(html).toContain("2 files delivered"); + expect(html).toContain("chart.png"); + expect(html).toContain("notes.txt"); + expect(html).toContain( + "/api/conversations/conversation-1/attachments/att-1", + ); + expect(html).toContain( + "/api/conversations/conversation-1/attachments/att-2", + ); + }); + it("keeps recalled memory context collapsed on its user message", () => { const html = renderTranscript( conversation([ diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 82a092ef7..6824305d3 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -19,6 +19,7 @@ export const conversationReportSourceEventTypes = [ "turn_started", "turn_context", "structured_event", + "attachments_delivered", "turn_routed", "turn_completed", "turn_failed", @@ -358,6 +359,16 @@ function reportEventData(args: { version: data.version, content: data.content, }; + case "attachments_delivered": + if (!args.canExposePayload) { + return undefined; + } + return { + type: "attachments_delivered", + attachments: data.attachments, + ...(data.toolCallId ? { toolCallId: data.toolCallId } : {}), + ...(data.turnId ? { turnId: data.turnId } : {}), + }; case "turn_routed": return { type: "turn_routed", diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 8614e049f..08b5a41c8 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -440,6 +440,24 @@ const conversationReportStructuredEventDataSchema = z }) .strict(); +const conversationReportDeliveredAttachmentSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + contentType: z.string().min(1), + bytes: z.number().int().nonnegative().optional(), + }) + .strict(); + +const conversationReportAttachmentsDeliveredEventDataSchema = z + .object({ + type: z.literal("attachments_delivered"), + attachments: z.array(conversationReportDeliveredAttachmentSchema).min(1), + toolCallId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + }) + .strict(); + const conversationReportCompactionEventDataSchema = z .object({ type: z.literal("compaction"), @@ -494,6 +512,7 @@ export const conversationReportEventDataSchema = z.discriminatedUnion("type", [ conversationReportTurnLifecycleEventDataSchema, conversationReportTurnContextEventDataSchema, conversationReportStructuredEventDataSchema, + conversationReportAttachmentsDeliveredEventDataSchema, conversationReportTurnRoutedEventDataSchema, conversationReportGuardianActionReviewedEventDataSchema, conversationReportCompactionEventDataSchema, diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index dc5d79dfc..f41428bd1 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -303,6 +303,25 @@ const structuredConversationEventDataSchema = z }) .strict(); +const deliveredAttachmentSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + contentType: z.string().min(1), + bytes: z.number().int().nonnegative().optional(), + }) + .strict(); + +/** Host-owned transcript item for files delivered to humans this turn. */ +const attachmentsDeliveredEventDataSchema = z + .object({ + type: z.literal("attachments_delivered"), + attachments: z.array(deliveredAttachmentSchema).min(1), + toolCallId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + }) + .strict(); + const turnCompletedEventDataSchema = z .object({ type: z.literal("turn_completed"), @@ -365,6 +384,7 @@ const appendableConversationEventDataSchema = z.union([ turnStartedEventDataSchema, turnContextEventDataSchema, structuredConversationEventDataSchema, + attachmentsDeliveredEventDataSchema, turnRoutedEventDataSchema, turnCompletedEventDataSchema, turnFailedEventDataSchema, @@ -404,6 +424,7 @@ export const KNOWN_CONVERSATION_EVENT_TYPES = [ "turn_started", "turn_context", "structured_event", + "attachments_delivered", "turn_routed", "turn_completed", "turn_failed", diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 56f80b46b..76836e63c 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -843,6 +843,72 @@ export async function recordToolExecutionStarted(args: { ]); } +function attachmentsDeliveredIdempotencyKey(args: { + attachments: Array<{ id: string }>; + conversationId: string; + toolCallId?: string; + turnId?: string; +}): string { + if (args.toolCallId) { + return `attachments_delivered:tool:${args.toolCallId}`; + } + if (args.turnId) { + return ( + `attachments_delivered:turn:${args.turnId}:` + + args.attachments + .map((attachment) => attachment.id) + .sort() + .join(",") + ); + } + return ( + `attachments_delivered:${args.conversationId}:` + + args.attachments + .map((attachment) => attachment.id) + .sort() + .join(",") + ); +} + +/** Record files delivered for humans without adding them to Pi replay. */ +export async function recordAttachmentsDelivered(args: { + attachments: Array<{ + bytes?: number; + contentType: string; + id: string; + name: string; + }>; + conversationId: string; + createdAtMs?: number; + toolCallId?: string; + turnId?: string; +}): Promise { + if (args.attachments.length === 0) return; + await getConversationEventStore().append(args.conversationId, [ + { + // Retries after a successful Slack upload must not create duplicate rows. + idempotencyKey: attachmentsDeliveredIdempotencyKey({ + attachments: args.attachments, + conversationId: args.conversationId, + ...(args.toolCallId ? { toolCallId: args.toolCallId } : {}), + ...(args.turnId ? { turnId: args.turnId } : {}), + }), + data: { + type: "attachments_delivered", + attachments: args.attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + contentType: attachment.contentType, + ...(attachment.bytes !== undefined ? { bytes: attachment.bytes } : {}), + })), + ...(args.toolCallId ? { toolCallId: args.toolCallId } : {}), + ...(args.turnId ? { turnId: args.turnId } : {}), + }, + createdAtMs: args.createdAtMs ?? Date.now(), + }, + ]); +} + /** Record one privacy-safe Guardian decision before the reviewed action continues. */ export async function recordGuardianActionReviewed(args: { conversationId: string; diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index b8cd7581c..e71c6a441 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { storeAttachments } from "@/chat/attachments/store"; import type { AttachmentStorage } from "@/chat/attachments/storage"; +import { recordAttachmentsDelivered } from "@/chat/conversations/projection"; import type { JuniorSqlDatabase } from "@/db/db"; import { uploadFilesToConversation } from "@/chat/slack/outbound"; import type { SlackToolContext } from "@/chat/slack/tool-support/context"; @@ -34,6 +35,21 @@ const sendFilesResultSchema = juniorToolOutputSchema.extend({ type SendFilesResult = z.output; +type DeliveredAttachment = { + bytes?: number; + contentType: string; + id: string; + name: string; +}; + +/** Operation cache keeps delivery metadata so retries can re-record safely. */ +type CachedSendFiles = { + delivered: DeliveredAttachment[]; + result: SendFilesResult; + /** Identity used for the first delivery event; retries must reuse it. */ + toolCallId?: string; +}; + function normalizeFiles( files: SandboxFileReferenceInput[], ): SandboxFileMaterializationInput[] { @@ -88,7 +104,7 @@ export function createSendFilesTool( ), }), outputSchema: sendFilesResultSchema, - execute: async ({ files }) => { + execute: async ({ files }, options) => { const filesToSend = normalizeFiles(files); const activeChannelId = context.sourceChannelId; if (!activeChannelId) { @@ -108,10 +124,20 @@ export function createSendFilesTool( thread_ts: threadTs, files: fileOperationInput(materializedFiles), }); - const cached = state.getOperationResult(operationKey); + const cached = state.getOperationResult(operationKey); if (cached) { + // A prior attempt may have uploaded to Slack and cached before the + // transcript event landed. Re-record with the original delivery identity + // so a later toolCallId cannot mint a second transcript row. + if (attachments && cached.delivered.length > 0) { + await recordAttachmentsDelivered({ + attachments: cached.delivered, + conversationId: attachments.conversationId, + ...(cached.toolCallId ? { toolCallId: cached.toolCallId } : {}), + }); + } return sendFilesResultSchema.parse({ - ...cached, + ...cached.result, deduplicated: true, }); } @@ -133,13 +159,37 @@ export function createSendFilesTool( files: uploads, threadTs, }); + const delivered: DeliveredAttachment[] = stored.map( + (attachment, index) => { + const file = materializedFiles[index]!; + return { + id: attachment.id, + name: file.filename, + contentType: file.mimeType, + bytes: file.bytes, + }; + }, + ); const response: SendFilesResult = { - attachment_refs: stored.map((attachment, index) => ({ + attachment_refs: delivered.map((attachment) => ({ id: attachment.id, - name: materializedFiles[index]!.filename, + name: attachment.name, })), }; - state.setOperationResult(operationKey, response); + // Cache before host bookkeeping so a later event-write failure cannot + // cause another Slack upload on retry. + state.setOperationResult(operationKey, { + delivered, + result: response, + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + } satisfies CachedSendFiles); + if (attachments && delivered.length > 0) { + await recordAttachmentsDelivered({ + attachments: delivered, + conversationId: attachments.conversationId, + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + }); + } return response; }, }); diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index 1f561065b..0f4e92c2e 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -1,8 +1,14 @@ import { eq } from "drizzle-orm"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createSlackSource } from "@sentry/junior-plugin-api"; import type { AttachmentStorage } from "@/chat/attachments/storage"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { + closeDb, + getConversationEventStore, + getConversationStore, + getSqlExecutor, +} from "@/chat/db"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import { parseSlackChannelId, parseSlackTeamId } from "@/chat/slack/ids"; import { createSendFilesTool } from "@/chat/slack/tools/send-files"; @@ -11,7 +17,7 @@ import { parseSlackMessageTs } from "@/chat/slack/timestamp"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { readSandboxFileUpload } from "@/chat/tools/sandbox/file-uploads"; import type { ToolState } from "@/chat/tools/types"; -import { juniorAttachments, juniorConversations } from "@/db/schema"; +import { juniorAttachments } from "@/db/schema"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; import { getCapturedSlackApiCalls } from "../msw/handlers/slack-api"; @@ -124,14 +130,20 @@ function createMaterializeFile(files: Record = {}) { readSandboxFileUpload(sandbox, input); } -async function executeTool(tool: any, input: TInput) { +async function executeTool< + TInput, +>(tool: any, input: TInput, options: { toolCallId?: string } = {}) { if (typeof tool?.execute !== "function") { throw new Error("tool execute function missing"); } - return await tool.execute(input, {} as any); + return await tool.execute(input, options as any); } describe("Slack sendFiles", () => { + afterEach(async () => { + await closeDb(); + }); + it("sends file-only messages without posting empty text", async () => { const tool = createSendFilesTool( createContext("share this file"), @@ -276,160 +288,269 @@ describe("Slack sendFiles", () => { }); }); - it("stores files before Slack delivery", async () => { - const fixture = await createLocalJuniorSqlFixture(); - try { - await migrateSchema(fixture.sql); - const now = new Date("2026-08-12T17:00:00.000Z"); - await fixture.sql.db().insert(juniorConversations).values({ - conversationId: "conversation-1", - createdAt: now, - lastActivityAt: now, - updatedAt: now, - executionStatus: "idle", - }); - const puts: string[] = []; - const storage: AttachmentStorage = { - provider: "test", - get: async () => null, - put: async (input) => { - puts.push(input.key); - }, - delete: async () => undefined, - }; - const tool = createSendFilesTool( - createContext("attach the report"), - createToolState(), - createMaterializeFile({ - "/tmp/report.txt": Buffer.from("report body"), - }), - { - conversationId: "conversation-1", - db: fixture.sql, - storage, - }, - ); + it("stores files and records delivered attachment transcript items", async () => { + const conversationId = "conversation-1"; + await getConversationStore().recordActivity({ + conversationId, + destination: { + channelId: "C123", + platform: "slack", + teamId: "T123", + }, + nowMs: Date.parse("2026-08-12T17:00:00.000Z"), + source: "slack", + title: "Attachment delivery conversation", + visibility: "private", + }); + const puts: string[] = []; + const storage: AttachmentStorage = { + provider: "test", + get: async () => null, + put: async (input) => { + puts.push(input.key); + }, + delete: async () => undefined, + }; + const tool = createSendFilesTool( + createContext("attach the report"), + createToolState(), + createMaterializeFile({ + "/tmp/report.txt": Buffer.from("report body"), + }), + { + conversationId, + db: getSqlExecutor(), + storage, + }, + ); - const result = await executeTool(tool, { + const result = await executeTool( + tool, + { files: [{ path: "/tmp/report.txt" }], - }); - // Clear in-process tool dedupe so a later call exercises durable reuse. - const retryTool = createSendFilesTool( - createContext("attach the report again"), - createToolState(), - createMaterializeFile({ - "/tmp/report.txt": Buffer.from("report body"), - }), - { - conversationId: "conversation-1", - db: fixture.sql, - storage, - }, - ); - const retry = await executeTool(retryTool, { + }, + { toolCallId: "call-send-1" }, + ); + // Clear in-process tool dedupe so a later call exercises durable reuse. + const retryTool = createSendFilesTool( + createContext("attach the report again"), + createToolState(), + createMaterializeFile({ + "/tmp/report.txt": Buffer.from("report body"), + }), + { + conversationId, + db: getSqlExecutor(), + storage, + }, + ); + const retry = await executeTool( + retryTool, + { files: [{ path: "/tmp/report.txt" }], - }); - - const rows = await fixture.sql.db().select().from(juniorAttachments); - expect(result.attachment_refs).toEqual([ - { id: rows[0]?.id, name: "report.txt" }, - ]); - expect(retry.attachment_refs).toEqual([ - { id: rows[0]?.id, name: "report.txt" }, - ]); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ - conversationId: "conversation-1", - filename: "report.txt", - provider: "test", - }); - expect(puts).toEqual([rows[0]?.storageKey]); - expect( - getCapturedSlackApiCalls("files.completeUploadExternal"), - ).toHaveLength(2); - } finally { - await fixture.close(); - } - }); + }, + { toolCallId: "call-send-2" }, + ); - it("revives a purge-marked attachment on later store", async () => { - const fixture = await createLocalJuniorSqlFixture(); - try { - await migrateSchema(fixture.sql); - const now = new Date("2026-08-12T17:00:00.000Z"); - await fixture.sql.db().insert(juniorConversations).values({ - conversationId: "conversation-1", - createdAt: now, - lastActivityAt: now, - updatedAt: now, - executionStatus: "idle", - }); - const puts: string[] = []; - const storage: AttachmentStorage = { - provider: "test", - get: async () => null, - put: async (input) => { - puts.push(input.key); - }, - delete: async () => undefined, - }; - const firstTool = createSendFilesTool( - createContext("attach the report"), - createToolState(), - createMaterializeFile({ - "/tmp/report.txt": Buffer.from("report body"), - }), + const rows = await getSqlExecutor().db().select().from(juniorAttachments); + expect(result.attachment_refs).toEqual([ + { id: rows[0]?.id, name: "report.txt" }, + ]); + expect(retry.attachment_refs).toEqual([ + { id: rows[0]?.id, name: "report.txt" }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + conversationId, + filename: "report.txt", + provider: "test", + }); + expect(puts).toEqual([rows[0]?.storageKey]); + expect( + getCapturedSlackApiCalls("files.completeUploadExternal"), + ).toHaveLength(2); + + const history = + await getConversationEventStore().loadHistory(conversationId); + const delivered = history.filter( + (event) => event.data.type === "attachments_delivered", + ); + // One durable delivery item per successful store/send, including reuse. + expect(delivered).toHaveLength(2); + expect(delivered[0]?.data).toMatchObject({ + type: "attachments_delivered", + toolCallId: "call-send-1", + attachments: [ { - conversationId: "conversation-1", - db: fixture.sql, - storage, + id: rows[0]?.id, + name: "report.txt", + contentType: "text/plain", + bytes: Buffer.byteLength("report body"), }, - ); - const first = await executeTool(firstTool, { - files: [{ path: "/tmp/report.txt" }], - }); - const attachmentId = first.attachment_refs[0]?.id; - expect(first.attachment_refs).toEqual([ - { id: expect.any(String), name: "report.txt" }, - ]); - - await fixture.sql - .db() - .update(juniorAttachments) - .set({ deleteRequestedAt: now }) - .where(eq(juniorAttachments.id, attachmentId!)); - - const retryTool = createSendFilesTool( - createContext("attach the report again"), - createToolState(), - createMaterializeFile({ - "/tmp/report.txt": Buffer.from("report body"), - }), + ], + }); + expect(delivered[1]?.data).toMatchObject({ + type: "attachments_delivered", + toolCallId: "call-send-2", + }); + }); + + it("does not re-upload or mint a second delivery item on a cached retry", async () => { + const conversationId = "conversation-cached-send"; + await getConversationStore().recordActivity({ + conversationId, + destination: { + channelId: "C123", + platform: "slack", + teamId: "T123", + }, + nowMs: Date.parse("2026-08-12T17:00:00.000Z"), + source: "slack", + title: "Cached attachment delivery", + visibility: "private", + }); + const storage: AttachmentStorage = { + provider: "test", + get: async () => null, + put: async () => undefined, + delete: async () => undefined, + }; + const state = createToolState(); + const tool = createSendFilesTool( + createContext("attach the report"), + state, + createMaterializeFile({ + "/tmp/report.txt": Buffer.from("report body"), + }), + { + conversationId, + db: getSqlExecutor(), + storage, + }, + ); + + const first = await executeTool( + tool, + { files: [{ path: "/tmp/report.txt" }] }, + { toolCallId: "call-send-cached" }, + ); + // A later tool call with the same bytes must reuse the original delivery + // identity, not create another transcript row under a new toolCallId. + const second = await executeTool( + tool, + { files: [{ path: "/tmp/report.txt" }] }, + { toolCallId: "call-send-later" }, + ); + + expect(first.attachment_refs).toEqual([ + { id: expect.any(String), name: "report.txt" }, + ]); + expect(second).toMatchObject({ + deduplicated: true, + attachment_refs: first.attachment_refs, + }); + expect( + getCapturedSlackApiCalls("files.completeUploadExternal"), + ).toHaveLength(1); + + const history = + await getConversationEventStore().loadHistory(conversationId); + const delivered = history.filter( + (event) => event.data.type === "attachments_delivered", + ); + expect(delivered).toHaveLength(1); + expect(delivered[0]?.data).toMatchObject({ + type: "attachments_delivered", + toolCallId: "call-send-cached", + attachments: [ { - conversationId: "conversation-1", - db: fixture.sql, - storage, + id: first.attachment_refs[0]?.id, + name: "report.txt", + contentType: "text/plain", + bytes: Buffer.byteLength("report body"), }, - ); - const retry = await executeTool(retryTool, { - files: [{ path: "/tmp/report.txt" }], - }); - - const rows = await fixture.sql.db().select().from(juniorAttachments); - expect(retry.attachment_refs).toEqual([ - { id: attachmentId, name: "report.txt" }, - ]); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ - id: attachmentId, - deleteRequestedAt: null, - storageKey: puts[1], - }); - expect(puts).toHaveLength(2); - expect(puts[0]).not.toBe(puts[1]); - } finally { - await fixture.close(); - } + ], + }); + }); + + it("revives a purge-marked attachment on later store", async () => { + const conversationId = "conversation-1"; + const now = new Date("2026-08-12T17:00:00.000Z"); + await getConversationStore().recordActivity({ + conversationId, + destination: { + channelId: "C123", + platform: "slack", + teamId: "T123", + }, + nowMs: now.getTime(), + source: "slack", + title: "Attachment revive conversation", + visibility: "private", + }); + const puts: string[] = []; + const storage: AttachmentStorage = { + provider: "test", + get: async () => null, + put: async (input) => { + puts.push(input.key); + }, + delete: async () => undefined, + }; + const firstTool = createSendFilesTool( + createContext("attach the report"), + createToolState(), + createMaterializeFile({ + "/tmp/report.txt": Buffer.from("report body"), + }), + { + conversationId, + db: getSqlExecutor(), + storage, + }, + ); + const first = await executeTool(firstTool, { + files: [{ path: "/tmp/report.txt" }], + }); + const attachmentId = first.attachment_refs[0]?.id; + expect(first.attachment_refs).toEqual([ + { id: expect.any(String), name: "report.txt" }, + ]); + + await getSqlExecutor() + .db() + .update(juniorAttachments) + .set({ deleteRequestedAt: now }) + .where(eq(juniorAttachments.id, attachmentId!)); + + const retryTool = createSendFilesTool( + createContext("attach the report again"), + createToolState(), + createMaterializeFile({ + "/tmp/report.txt": Buffer.from("report body"), + }), + { + conversationId, + db: getSqlExecutor(), + storage, + }, + ); + const retry = await executeTool(retryTool, { + files: [{ path: "/tmp/report.txt" }], + }); + + const rows = await getSqlExecutor().db().select().from(juniorAttachments); + expect(retry.attachment_refs).toEqual([ + { id: attachmentId, name: "report.txt" }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: attachmentId, + deleteRequestedAt: null, + storageKey: puts[1], + }); + expect(puts).toHaveLength(2); + expect(puts[0]).not.toBe(puts[1]); }); it("deletes the blob when SQL insert fails after put", async () => { diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index d02c8d28c..59eeda1cd 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -230,6 +230,52 @@ describe("conversation report event projection", () => { ]); }); + it("projects delivered attachments only when payload is visible", () => { + const delivered = event(1, { + type: "attachments_delivered", + attachments: [ + { + id: "att-1", + name: "chart.png", + contentType: "image/png", + bytes: 18211, + }, + ], + toolCallId: "call-send-1", + }); + + expect( + projectConversationReportEventPage({ + canExposePayload: true, + events: [delivered], + }), + ).toEqual([ + { + seq: 1, + createdAt: "1970-01-01T00:00:01.000Z", + data: { + type: "attachments_delivered", + attachments: [ + { + id: "att-1", + name: "chart.png", + contentType: "image/png", + bytes: 18211, + }, + ], + toolCallId: "call-send-1", + }, + }, + ]); + + expect( + projectConversationReportEventPage({ + canExposePayload: false, + events: [delivered], + }), + ).toEqual([]); + }); + it("projects turn input message ids across report pages", () => { const events = [ event(1, {