From aa8b933171b63cdfa7238fd08617ccb3e0915ea0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:52 +0000 Subject: [PATCH 1/9] feat(attachments): Emit and render delivered attachment transcript items Add a host-owned attachments_delivered conversation event so successful sendFiles stores become first-class dashboard transcript media instead of tool-output scraping. Project the event through the report API and render inline images or download cards via the existing attachment route. Co-Authored-By: David Cramer --- .../conversations/ConversationTranscript.tsx | 29 ++++ .../conversations/TranscriptActivityGroup.tsx | 6 + .../TranscriptAttachmentsDeliveredView.tsx | 126 ++++++++++++++++++ .../conversations/TranscriptRailEvent.tsx | 8 ++ .../client/conversations/eventTranscript.ts | 13 ++ .../conversations/transcriptBottomPinning.ts | 10 ++ .../conversations/transcriptRenderModel.ts | 21 +++ .../client/conversations/transcriptSearch.tsx | 8 ++ .../src/client/markdownExport.ts | 16 +++ packages/junior-dashboard/src/client/types.ts | 14 ++ .../tests/transcriptRenderModel.test.ts | 62 +++++++++ .../junior/src/api/conversations/events.ts | 11 ++ .../junior/src/api/schema/conversation.ts | 19 +++ .../junior/src/chat/conversations/history.ts | 21 +++ .../src/chat/conversations/projection.ts | 36 +++++ .../junior/src/chat/slack/tools/send-files.ts | 25 +++- .../integration/slack-send-files.test.ts | 51 +++++-- .../unit/api/conversation-events.test.ts | 46 +++++++ 18 files changed, 511 insertions(+), 11 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index a9e2de62ac..bb7dcc6f23 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 0000000000..ed3e962aae --- /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 9faaf3fd66..53a08994fd 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 27b5df8b50..0c6e55ec48 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 21c5b6ee25..52572b1553 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 9d6c0ece4f..7c12406002 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 ca018ce45c..e647c099b1 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 73dbf72413..291602569b 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 dfe715b1a0..83ae3ad620 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/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index 4865f510f9..2d0da659a8 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -997,4 +997,66 @@ describe("transcript render grouping", () => { expect(currentKeys).toEqual(["tool:search-10", "11:message:0"]); expect(prependedKeys.slice(-currentKeys.length)).toEqual(currentKeys); }); + + it("projects delivered attachments as a first-class transcript entry", () => { + const messages = conversationTranscriptMessages( + conversation([ + event(4, "2026-01-01T00:00:04.000Z", { + type: "attachments_delivered", + attachments: [ + { + id: "att-1", + name: "chart.png", + contentType: "image/png", + bytes: 18211, + }, + { + id: "att-2", + name: "notes.txt", + contentType: "text/plain", + bytes: 42, + }, + ], + toolCallId: "call-send-1", + }), + ]), + ); + + expect(messages).toEqual([ + { + role: "system", + sourceSeq: 4, + timestamp: Date.parse("2026-01-01T00:00:04.000Z"), + parts: [ + { + 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(groupTranscriptMessages(messages)).toEqual([ + { + key: "4:attachments-delivered", + kind: "attachments_delivered", + timestamp: Date.parse("2026-01-01T00:00:04.000Z"), + part: messages[0]!.parts[0], + }, + ]); + expect(messageRawText(messages[0]!)).toBe("chart.png\nnotes.txt"); + }); }); diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 82a092ef7b..6824305d33 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 8614e049f4..08b5a41c86 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 dc5d79dfcf..f41428bd1f 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 56f80b46b8..64c5e79580 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -14,6 +14,7 @@ import { import type { AuthorizationKind, ConversationEvent, + ConversationEventStore, } from "@/chat/conversations/history"; import type { RepositoryInstructions } from "@/chat/repository-instructions"; import { @@ -843,6 +844,41 @@ export async function recordToolExecutionStarted(args: { ]); } +/** 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; + /** Defaults to the process conversation event store. */ + eventStore?: ConversationEventStore; + toolCallId?: string; + turnId?: string; +}): Promise { + if (args.attachments.length === 0) return; + const eventStore = args.eventStore ?? getConversationEventStore(); + await eventStore.append(args.conversationId, [ + { + 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 b8cd7581cc..8a82464e52 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -1,6 +1,8 @@ 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 { createSqlConversationEventStore } from "@/chat/conversations/sql/history"; import type { JuniorSqlDatabase } from "@/db/db"; import { uploadFilesToConversation } from "@/chat/slack/outbound"; import type { SlackToolContext } from "@/chat/slack/tool-support/context"; @@ -88,7 +90,7 @@ export function createSendFilesTool( ), }), outputSchema: sendFilesResultSchema, - execute: async ({ files }) => { + execute: async ({ files }, options) => { const filesToSend = normalizeFiles(files); const activeChannelId = context.sourceChannelId; if (!activeChannelId) { @@ -133,10 +135,27 @@ export function createSendFilesTool( files: uploads, threadTs, }); + const delivered = stored.map((attachment, index) => { + const file = materializedFiles[index]!; + return { + id: attachment.id, + name: file.filename, + contentType: file.mimeType, + bytes: file.bytes, + }; + }); + if (attachments && delivered.length > 0) { + await recordAttachmentsDelivered({ + attachments: delivered, + conversationId: attachments.conversationId, + eventStore: createSqlConversationEventStore(attachments.db), + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + }); + } 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); diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index 1f561065bc..ec6e08f3db 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { createSlackSource } from "@sentry/junior-plugin-api"; import type { AttachmentStorage } from "@/chat/attachments/storage"; +import { createSqlConversationEventStore } from "@/chat/conversations/sql/history"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import { parseSlackChannelId, parseSlackTeamId } from "@/chat/slack/ids"; @@ -124,11 +125,13 @@ 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", () => { @@ -310,9 +313,13 @@ describe("Slack sendFiles", () => { }, ); - const result = await executeTool(tool, { - files: [{ path: "/tmp/report.txt" }], - }); + const result = await executeTool( + tool, + { + files: [{ path: "/tmp/report.txt" }], + }, + { toolCallId: "call-send-1" }, + ); // Clear in-process tool dedupe so a later call exercises durable reuse. const retryTool = createSendFilesTool( createContext("attach the report again"), @@ -326,9 +333,13 @@ describe("Slack sendFiles", () => { storage, }, ); - const retry = await executeTool(retryTool, { - files: [{ path: "/tmp/report.txt" }], - }); + const retry = await executeTool( + retryTool, + { + files: [{ path: "/tmp/report.txt" }], + }, + { toolCallId: "call-send-2" }, + ); const rows = await fixture.sql.db().select().from(juniorAttachments); expect(result.attachment_refs).toEqual([ @@ -347,6 +358,30 @@ describe("Slack sendFiles", () => { expect( getCapturedSlackApiCalls("files.completeUploadExternal"), ).toHaveLength(2); + + const history = await createSqlConversationEventStore( + fixture.sql, + ).loadHistory("conversation-1"); + const delivered = history.filter( + (event) => event.data.type === "attachments_delivered", + ); + expect(delivered).toHaveLength(2); + expect(delivered[0]?.data).toMatchObject({ + type: "attachments_delivered", + toolCallId: "call-send-1", + attachments: [ + { + id: rows[0]?.id, + name: "report.txt", + contentType: "text/plain", + bytes: Buffer.byteLength("report body"), + }, + ], + }); + expect(delivered[1]?.data).toMatchObject({ + type: "attachments_delivered", + toolCallId: "call-send-2", + }); } finally { await fixture.close(); } diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index d02c8d28c2..59eeda1cd1 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, { From abf759852fd8bdc6ca05cd54e9f27964bc9eccdb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:20:16 +0000 Subject: [PATCH 2/9] test(attachments): Align delivered-attachment coverage with testing policy Move dashboard coverage to the owning semantic render suite, keep emit coverage on the product SQL harness, and drop the oversized pure-projection case that broke the file-length limit. Co-Authored-By: David Cramer --- .../tests/telemetry-components.test.tsx | 38 ++ .../tests/transcriptRenderModel.test.ts | 62 --- .../src/chat/conversations/projection.ts | 6 +- .../junior/src/chat/slack/tools/send-files.ts | 2 - .../integration/slack-send-files.test.ts | 374 +++++++++--------- 5 files changed, 230 insertions(+), 252 deletions(-) diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index bb3c886dad..aacfa4c368 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-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index 2d0da659a8..4865f510f9 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -997,66 +997,4 @@ describe("transcript render grouping", () => { expect(currentKeys).toEqual(["tool:search-10", "11:message:0"]); expect(prependedKeys.slice(-currentKeys.length)).toEqual(currentKeys); }); - - it("projects delivered attachments as a first-class transcript entry", () => { - const messages = conversationTranscriptMessages( - conversation([ - event(4, "2026-01-01T00:00:04.000Z", { - type: "attachments_delivered", - attachments: [ - { - id: "att-1", - name: "chart.png", - contentType: "image/png", - bytes: 18211, - }, - { - id: "att-2", - name: "notes.txt", - contentType: "text/plain", - bytes: 42, - }, - ], - toolCallId: "call-send-1", - }), - ]), - ); - - expect(messages).toEqual([ - { - role: "system", - sourceSeq: 4, - timestamp: Date.parse("2026-01-01T00:00:04.000Z"), - parts: [ - { - 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(groupTranscriptMessages(messages)).toEqual([ - { - key: "4:attachments-delivered", - kind: "attachments_delivered", - timestamp: Date.parse("2026-01-01T00:00:04.000Z"), - part: messages[0]!.parts[0], - }, - ]); - expect(messageRawText(messages[0]!)).toBe("chart.png\nnotes.txt"); - }); }); diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 64c5e79580..aa58707a75 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -14,7 +14,6 @@ import { import type { AuthorizationKind, ConversationEvent, - ConversationEventStore, } from "@/chat/conversations/history"; import type { RepositoryInstructions } from "@/chat/repository-instructions"; import { @@ -854,14 +853,11 @@ export async function recordAttachmentsDelivered(args: { }>; conversationId: string; createdAtMs?: number; - /** Defaults to the process conversation event store. */ - eventStore?: ConversationEventStore; toolCallId?: string; turnId?: string; }): Promise { if (args.attachments.length === 0) return; - const eventStore = args.eventStore ?? getConversationEventStore(); - await eventStore.append(args.conversationId, [ + await getConversationEventStore().append(args.conversationId, [ { data: { type: "attachments_delivered", diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index 8a82464e52..648c32eb33 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -2,7 +2,6 @@ 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 { createSqlConversationEventStore } from "@/chat/conversations/sql/history"; import type { JuniorSqlDatabase } from "@/db/db"; import { uploadFilesToConversation } from "@/chat/slack/outbound"; import type { SlackToolContext } from "@/chat/slack/tool-support/context"; @@ -148,7 +147,6 @@ export function createSendFilesTool( await recordAttachmentsDelivered({ attachments: delivered, conversationId: attachments.conversationId, - eventStore: createSqlConversationEventStore(attachments.db), ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), }); } diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index ec6e08f3db..5c7eaff59a 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -1,9 +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 { createSqlConversationEventStore } from "@/chat/conversations/sql/history"; 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"; @@ -12,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"; @@ -135,6 +140,10 @@ async function executeTool< } describe("Slack sendFiles", () => { + afterEach(async () => { + await closeDb(); + }); + it("sends file-only messages without posting empty text", async () => { const tool = createSendFilesTool( createContext("share this file"), @@ -279,192 +288,191 @@ 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, - { - files: [{ path: "/tmp/report.txt" }], - }, - { 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: "conversation-1", - db: fixture.sql, - storage, - }, - ); - const retry = await executeTool( - retryTool, + const result = await executeTool( + tool, + { + files: [{ path: "/tmp/report.txt" }], + }, + { 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" }], + }, + { toolCallId: "call-send-2" }, + ); + + 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: [ { - files: [{ path: "/tmp/report.txt" }], + id: rows[0]?.id, + name: "report.txt", + contentType: "text/plain", + bytes: Buffer.byteLength("report body"), }, - { toolCallId: "call-send-2" }, - ); - - 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); - - const history = await createSqlConversationEventStore( - fixture.sql, - ).loadHistory("conversation-1"); - const delivered = history.filter( - (event) => event.data.type === "attachments_delivered", - ); - expect(delivered).toHaveLength(2); - expect(delivered[0]?.data).toMatchObject({ - type: "attachments_delivered", - toolCallId: "call-send-1", - attachments: [ - { - id: rows[0]?.id, - name: "report.txt", - contentType: "text/plain", - bytes: Buffer.byteLength("report body"), - }, - ], - }); - expect(delivered[1]?.data).toMatchObject({ - type: "attachments_delivered", - toolCallId: "call-send-2", - }); - } finally { - await fixture.close(); - } + ], + }); + expect(delivered[1]?.data).toMatchObject({ + type: "attachments_delivered", + 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"), - }), - { - conversationId: "conversation-1", - db: fixture.sql, - 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 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"), - }), - { - conversationId: "conversation-1", - db: fixture.sql, - storage, - }, - ); - 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(); - } + 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 () => { From b8e70904c1014631e63d2bbe9e4dc0d55a1f462e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:46:19 +0000 Subject: [PATCH 3/9] fix(attachments): Cache sendFiles before transcript bookkeeping Cache the successful Slack upload before writing attachments_delivered so a later event-store failure cannot cause a duplicate Slack upload on retry. Make the delivery event idempotent and re-record from the cache path. --- .../src/chat/conversations/projection.ts | 34 +++++++++ .../junior/src/chat/slack/tools/send-files.ts | 63 ++++++++++----- .../integration/slack-send-files.test.ts | 76 +++++++++++++++++++ 3 files changed, 156 insertions(+), 17 deletions(-) diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index aa58707a75..76836e63cd 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -843,6 +843,33 @@ 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<{ @@ -859,6 +886,13 @@ export async function recordAttachmentsDelivered(args: { 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) => ({ diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index 648c32eb33..514ee97a09 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -35,6 +35,19 @@ 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; +}; + function normalizeFiles( files: SandboxFileReferenceInput[], ): SandboxFileMaterializationInput[] { @@ -109,10 +122,19 @@ 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 idempotently without re-uploading. + if (attachments && cached.delivered.length > 0) { + await recordAttachmentsDelivered({ + attachments: cached.delivered, + conversationId: attachments.conversationId, + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + }); + } return sendFilesResultSchema.parse({ - ...cached, + ...cached.result, deduplicated: true, }); } @@ -134,15 +156,29 @@ export function createSendFilesTool( files: uploads, threadTs, }); - const delivered = stored.map((attachment, index) => { - const file = materializedFiles[index]!; - return { + 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: delivered.map((attachment) => ({ id: attachment.id, - name: file.filename, - contentType: file.mimeType, - bytes: file.bytes, - }; - }); + name: attachment.name, + })), + }; + // Cache before host bookkeeping so a later event-write failure cannot + // cause another Slack upload on retry. + state.setOperationResult(operationKey, { + delivered, + result: response, + } satisfies CachedSendFiles); if (attachments && delivered.length > 0) { await recordAttachmentsDelivered({ attachments: delivered, @@ -150,13 +186,6 @@ export function createSendFilesTool( ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), }); } - const response: SendFilesResult = { - attachment_refs: delivered.map((attachment) => ({ - id: attachment.id, - name: attachment.name, - })), - }; - state.setOperationResult(operationKey, response); 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 5c7eaff59a..d8a1c4cfa3 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -395,6 +395,82 @@ describe("Slack sendFiles", () => { }); }); + it("does not re-upload to Slack when retrying after a cached send", 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" }, + ); + const second = await executeTool( + tool, + { files: [{ path: "/tmp/report.txt" }] }, + { toolCallId: "call-send-cached" }, + ); + + 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: [ + { + id: first.attachment_refs[0]?.id, + name: "report.txt", + contentType: "text/plain", + bytes: Buffer.byteLength("report body"), + }, + ], + }); + }); + it("revives a purge-marked attachment on later store", async () => { const conversationId = "conversation-1"; const now = new Date("2026-08-12T17:00:00.000Z"); From a22605558852c4bcb9451db0649a9dbc464f6abc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:51:12 +0000 Subject: [PATCH 4/9] fix(attachments): Reuse original delivery identity on cached sendFiles Cache hits must re-record attachments_delivered with the first toolCallId, not the current one. Otherwise a later call with the same bytes can mint a second transcript delivery that was never sent to Slack. --- packages/junior/src/chat/slack/tools/send-files.ts | 8 ++++++-- .../junior/tests/integration/slack-send-files.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index 514ee97a09..e71c6a4416 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -46,6 +46,8 @@ type DeliveredAttachment = { type CachedSendFiles = { delivered: DeliveredAttachment[]; result: SendFilesResult; + /** Identity used for the first delivery event; retries must reuse it. */ + toolCallId?: string; }; function normalizeFiles( @@ -125,12 +127,13 @@ export function createSendFilesTool( const cached = state.getOperationResult(operationKey); if (cached) { // A prior attempt may have uploaded to Slack and cached before the - // transcript event landed. Re-record idempotently without re-uploading. + // 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, - ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + ...(cached.toolCallId ? { toolCallId: cached.toolCallId } : {}), }); } return sendFilesResultSchema.parse({ @@ -178,6 +181,7 @@ export function createSendFilesTool( state.setOperationResult(operationKey, { delivered, result: response, + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), } satisfies CachedSendFiles); if (attachments && delivered.length > 0) { await recordAttachmentsDelivered({ diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index d8a1c4cfa3..0f4e92c2e5 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -395,7 +395,7 @@ describe("Slack sendFiles", () => { }); }); - it("does not re-upload to Slack when retrying after a cached send", async () => { + 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, @@ -434,10 +434,12 @@ describe("Slack sendFiles", () => { { 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-cached" }, + { toolCallId: "call-send-later" }, ); expect(first.attachment_refs).toEqual([ From e9c1cde30cf1548bf03fe0c92832b3b052afd40c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:33:07 +0000 Subject: [PATCH 5/9] fix(dashboard): Flatten attachments-delivered transcript chrome Make delivered files read as a quiet transcript item instead of a nested alert card: neutral surface, smaller image preview, filename rows as the download target, and keep the event out of activity groups. Co-Authored-By: David Cramer --- .../conversations/TranscriptActivityGroup.tsx | 2 + .../TranscriptAttachmentsDeliveredView.tsx | 80 ++++++++++--------- .../conversations/TranscriptRailEvent.tsx | 2 +- .../src/mock-reporting/fixtures.ts | 26 +++++- .../src/mock-reporting/routes.ts | 35 ++++++++ 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx index 5bc8920857..f7ddd0b180 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx @@ -16,6 +16,8 @@ export function isCollapsibleActivityEntry( entry: RenderedTranscriptEntry, ): boolean { if (entry.kind === "failure") return false; + // Delivered files are human-facing media, not collapsible tool chrome. + if (entry.kind === "attachments_delivered") return false; if (entry.kind === "message") return Boolean(entry.message.eventType); return true; } diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx index ed3e962aae..71108af5a0 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx @@ -1,4 +1,4 @@ -import { Download, FileText, Image as ImageIcon } from "lucide-react"; +import { FileText, Image as ImageIcon } from "lucide-react"; import { formatMessageTimestamp } from "../format"; import type { @@ -6,6 +6,10 @@ import type { TranscriptViewAttachmentsDeliveredPart, TranscriptViewDeliveredAttachment, } from "../types"; +import { + TranscriptHeadingMeta, + TranscriptHeadingRow, +} from "./TranscriptHeadingRow"; import { HighlightText, useTranscriptSearch } from "./transcriptSearch"; function mayDisplayInline(contentType: string): boolean { @@ -31,7 +35,7 @@ function formatAttachmentBytes(bytes: number | undefined): string | undefined { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -function AttachmentCard(props: { +function AttachmentItem(props: { attachment: TranscriptViewDeliveredAttachment; conversationId: string; }) { @@ -39,53 +43,50 @@ function AttachmentCard(props: { const href = attachmentUrl(props.conversationId, props.attachment.id); const size = formatAttachmentBytes(props.attachment.bytes); const inline = mayDisplayInline(props.attachment.contentType); + const meta = [props.attachment.contentType, size] + .filter((value): value is string => value !== undefined) + .join(" · "); return ( -
+
{inline && !search.active ? ( {props.attachment.name} ) : null} - ); } @@ -101,26 +102,31 @@ export function TranscriptAttachmentsDeliveredView(props: { const title = count === 1 ? "1 file delivered" : `${count} files delivered`; return ( -
-
-
- -
- {timestamp ? ( - - {timestamp} +
+ + - ) : null} -
+ } + leftClassName="min-w-0" + right={ + timestamp ? ( + + {timestamp} + + ) : undefined + } + />
{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 53a08994fd..4900c749a9 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptRailEvent.tsx @@ -77,7 +77,7 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): { } if (kind === "attachments_delivered") { return { - className: "text-sky-200", + className: "text-dashboard-text-muted", icon: Paperclip, }; } diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index 57e30ed535..5c699e2d88 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -527,7 +527,25 @@ Run targeted tests before broad suites, and keep durable explanations beside the ], }, }), - reportEvent(15, iso(Date.parse(startedAt), 64_000), { + reportEvent(15, iso(Date.parse(startedAt), 63_000), { + type: "attachments_delivered", + toolCallId: "qa-send-files", + attachments: [ + { + id: "qa-chart-png", + name: "chart.png", + contentType: "image/png", + bytes: 18211, + }, + { + id: "qa-notes-txt", + name: "notes.txt", + contentType: "text/plain", + bytes: 42, + }, + ], + }), + reportEvent(16, iso(Date.parse(startedAt), 64_000), { type: "message", messageId: "qa-unused-context", role: "user", @@ -535,7 +553,7 @@ Run targeted tests before broad suites, and keep durable explanations beside the explicitMention: false, actorIdentity: actor(undefined, "Alex Rivera", "alex"), }), - reportEvent(16, iso(Date.parse(startedAt), 66_000), { + reportEvent(17, iso(Date.parse(startedAt), 66_000), { type: "message", messageId: "qa-used-context", role: "user", @@ -543,13 +561,13 @@ Run targeted tests before broad suites, and keep durable explanations beside the explicitMention: false, actorIdentity: actor(undefined, "Alex Rivera", "alex"), }), - reportEvent(17, iso(Date.parse(startedAt), 67_000), { + reportEvent(18, iso(Date.parse(startedAt), 67_000), { type: "turn_lifecycle", turnId: "qa-context-turn", state: "started", inputMessageIds: ["qa-used-context"], }), - reportEvent(18, iso(Date.parse(startedAt), 69_000), { + reportEvent(19, iso(Date.parse(startedAt), 69_000), { type: "message", messageId: "qa-context-answer", role: "assistant", diff --git a/packages/junior-dashboard/src/mock-reporting/routes.ts b/packages/junior-dashboard/src/mock-reporting/routes.ts index ec68f7afca..6b02cbe40e 100644 --- a/packages/junior-dashboard/src/mock-reporting/routes.ts +++ b/packages/junior-dashboard/src/mock-reporting/routes.ts @@ -144,6 +144,41 @@ export function createMockReportingApi(): Hono<{ ? jsonResponse(conversationDetailReportSchema, report) : errorResponse("Conversation not found.", 404); }); + // Tiny fixed bodies so dashboard mock can exercise image/file attachment cards. + app.get("/conversations/:conversationId/attachments/:attachmentId", (c) => { + const conversationId = c.req.param("conversationId"); + const attachmentId = c.req.param("attachmentId"); + if (!conversationId || !attachmentId) { + return errorResponse("Invalid route parameters.", 400); + } + if (!readMockConversationDetail(conversationId)) { + return errorResponse("Conversation not found.", 404); + } + if (attachmentId.endsWith("-png") || attachmentId.includes("png")) { + // 640x360 mock chart PNG so image cards exercise natural aspect preview. + const png = Buffer.from( + "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", + "base64", + ); + return new Response(png, { + headers: { + "cache-control": "private, no-store", + "content-disposition": 'inline; filename="chart.png"', + "content-type": "image/png", + "content-length": String(png.byteLength), + }, + }); + } + const body = "mock attachment notes\n"; + return new Response(body, { + headers: { + "cache-control": "private, no-store", + "content-disposition": 'attachment; filename="notes.txt"', + "content-type": "text/plain", + "content-length": String(Buffer.byteLength(body)), + }, + }); + }); app.get("/tasks", () => jsonResponse(taskListSchema, readMockTaskList())); app.get("/tasks/runs", () => { const tasks = readMockTaskList().tasks; From 43d58297f9e717a82b11aea81e67d91c0f2c1f06 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:39:00 +0000 Subject: [PATCH 6/9] fix(dashboard): Drop one-off white hover on attachment rows dashboard-style:check rejects bare text-white; keep the shared dashboard text token and a background-only hover affordance. --- .../conversations/TranscriptAttachmentsDeliveredView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx index 71108af5a0..a3fbc3c8b7 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx @@ -65,7 +65,7 @@ function AttachmentItem(props: { ) : null} : }
-
+
{meta ? ( From ea87a6189e7a756f88eb7a512f3daf348fb300ca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:46:29 +0000 Subject: [PATCH 7/9] fix(attachments): Drop toolCallId from delivered report surface toolCallId/turnId only matter for write-path idempotency on the stored event. The report and dashboard only need the attachment list. --- .../src/client/conversations/eventTranscript.ts | 1 - .../src/client/conversations/transcriptBottomPinning.ts | 1 - packages/junior-dashboard/src/client/types.ts | 1 - packages/junior-dashboard/src/mock-reporting/fixtures.ts | 1 - packages/junior-dashboard/tests/telemetry-components.test.tsx | 1 - packages/junior/src/api/conversations/events.ts | 3 +-- packages/junior/src/api/schema/conversation.ts | 2 -- packages/junior/tests/unit/api/conversation-events.test.ts | 2 +- 8 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 0c6e55ec48..db079d9c87 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -364,7 +364,6 @@ export function conversationTranscriptMessages( { type: "attachments_delivered", attachments: data.attachments, - ...(data.toolCallId ? { toolCallId: data.toolCallId } : {}), }, ]), ); diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 52572b1553..577568cca0 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -392,7 +392,6 @@ function transcriptPartVersion(part: TranscriptViewPart | undefined): string { if (part.type === "attachments_delivered") { return [ part.type, - part.toolCallId ?? "", ...part.attachments.map( (attachment) => `${attachment.id}:${attachment.name}:${attachment.contentType}:${attachment.bytes ?? ""}`, diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 83ae3ad620..04640b7431 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -87,7 +87,6 @@ export type TranscriptViewDeliveredAttachment = { export type TranscriptViewAttachmentsDeliveredPart = { attachments: TranscriptViewDeliveredAttachment[]; - toolCallId?: string; type: "attachments_delivered"; }; diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index 5c699e2d88..761442053d 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -529,7 +529,6 @@ Run targeted tests before broad suites, and keep durable explanations beside the }), reportEvent(15, iso(Date.parse(startedAt), 63_000), { type: "attachments_delivered", - toolCallId: "qa-send-files", attachments: [ { id: "qa-chart-png", diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index aacfa4c368..18a2484329 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -677,7 +677,6 @@ describe("dashboard canonical-event components", () => { conversation([ event(0, { type: "attachments_delivered", - toolCallId: "call-send-1", attachments: [ { id: "att-1", diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 6824305d33..63bb4b3f63 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -365,9 +365,8 @@ function reportEventData(args: { } return { type: "attachments_delivered", + // toolCallId/turnId stay on the stored event for write idempotency only. attachments: data.attachments, - ...(data.toolCallId ? { toolCallId: data.toolCallId } : {}), - ...(data.turnId ? { turnId: data.turnId } : {}), }; case "turn_routed": return { diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 08b5a41c86..2f308d24f1 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -453,8 +453,6 @@ 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(); diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 59eeda1cd1..5998cf74ff 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -241,6 +241,7 @@ describe("conversation report event projection", () => { bytes: 18211, }, ], + // Stored for write-path idempotency; not part of the report surface. toolCallId: "call-send-1", }); @@ -263,7 +264,6 @@ describe("conversation report event projection", () => { bytes: 18211, }, ], - toolCallId: "call-send-1", }, }, ]); From 219f9281db3562ee72b6713ea10ed56dfbe2e3ee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:51:40 +0000 Subject: [PATCH 8/9] fix(attachments): Stabilize public delivered-attachment fields Align report/event attachment metadata with storage: filename + required bytes. Keep toolCallId/turnId write-path only so the public surface stays minimal and consistent for clients. Co-Authored-By: David Cramer --- .../TranscriptAttachmentsDeliveredView.tsx | 21 +++++++------------ .../conversations/transcriptBottomPinning.ts | 2 +- .../conversations/transcriptRenderModel.ts | 2 +- .../client/conversations/transcriptSearch.tsx | 2 +- .../src/client/markdownExport.ts | 2 +- packages/junior-dashboard/src/client/types.ts | 4 ++-- .../src/mock-reporting/fixtures.ts | 4 ++-- .../tests/telemetry-components.test.tsx | 4 ++-- .../junior/src/api/schema/conversation.ts | 5 +++-- .../junior/src/chat/conversations/history.ts | 5 +++-- .../src/chat/conversations/projection.ts | 9 ++++---- .../junior/src/chat/slack/tools/send-files.ts | 9 ++++---- .../integration/slack-send-files.test.ts | 4 ++-- .../unit/api/conversation-events.test.ts | 4 ++-- 14 files changed, 38 insertions(+), 39 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx index a3fbc3c8b7..beb4c5e3f2 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptAttachmentsDeliveredView.tsx @@ -28,8 +28,7 @@ function attachmentUrl( return `/api/conversations/${encodeURIComponent(conversationId)}/attachments/${encodeURIComponent(attachmentId)}`; } -function formatAttachmentBytes(bytes: number | undefined): string | undefined { - if (bytes === undefined) return undefined; +function formatAttachmentBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; @@ -43,9 +42,7 @@ function AttachmentItem(props: { const href = attachmentUrl(props.conversationId, props.attachment.id); const size = formatAttachmentBytes(props.attachment.bytes); const inline = mayDisplayInline(props.attachment.contentType); - const meta = [props.attachment.contentType, size] - .filter((value): value is string => value !== undefined) - .join(" · "); + const meta = [props.attachment.contentType, size].join(" · "); return (
diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 577568cca0..ec17249cc6 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -394,7 +394,7 @@ function transcriptPartVersion(part: TranscriptViewPart | undefined): string { part.type, ...part.attachments.map( (attachment) => - `${attachment.id}:${attachment.name}:${attachment.contentType}:${attachment.bytes ?? ""}`, + `${attachment.id}:${attachment.filename}:${attachment.contentType}:${attachment.bytes}`, ), ].join(":"); } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index 7c12406002..6f2e5c2fa2 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -190,7 +190,7 @@ export function messageRawText(message: TranscriptViewMessage): string { } if (part.type === "attachments_delivered") { return part.attachments - .map((attachment) => attachment.name) + .map((attachment) => attachment.filename) .join("\n"); } if (part.event.type !== "handoff") { diff --git a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx index e647c099b1..a73236f883 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx +++ b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx @@ -178,7 +178,7 @@ export function entryMatchesSearch( if (entry.kind === "attachments_delivered") { return entry.part.attachments.some( (attachment) => - textContains(attachment.name, normalizedQuery) || + textContains(attachment.filename, normalizedQuery) || textContains(attachment.contentType, normalizedQuery), ); } diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index 291602569b..2224f1e44c 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -189,7 +189,7 @@ function appendTranscriptMessages( for (const attachment of entry.part.attachments) { lines.push( "", - `- ${attachment.name} (${attachment.contentType}${attachment.bytes !== undefined ? `, ${attachment.bytes} bytes` : ""})`, + `- ${attachment.filename} (${attachment.contentType}, ${attachment.bytes} bytes)`, ); } continue; diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 04640b7431..68fed70487 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -79,10 +79,10 @@ export type TranscriptViewStructuredEventPart = { }; export type TranscriptViewDeliveredAttachment = { - bytes?: number; + bytes: number; contentType: string; + filename: string; id: string; - name: string; }; export type TranscriptViewAttachmentsDeliveredPart = { diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index 761442053d..4b640da540 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -532,13 +532,13 @@ Run targeted tests before broad suites, and keep durable explanations beside the attachments: [ { id: "qa-chart-png", - name: "chart.png", + filename: "chart.png", contentType: "image/png", bytes: 18211, }, { id: "qa-notes-txt", - name: "notes.txt", + filename: "notes.txt", contentType: "text/plain", bytes: 42, }, diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 18a2484329..1a40dc52c8 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -680,13 +680,13 @@ describe("dashboard canonical-event components", () => { attachments: [ { id: "att-1", - name: "chart.png", + filename: "chart.png", contentType: "image/png", bytes: 18211, }, { id: "att-2", - name: "notes.txt", + filename: "notes.txt", contentType: "text/plain", bytes: 42, }, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 2f308d24f1..d144033db7 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -443,9 +443,10 @@ const conversationReportStructuredEventDataSchema = z const conversationReportDeliveredAttachmentSchema = z .object({ id: z.string().min(1), - name: z.string().min(1), + // Stable public fields: same names as attachment storage metadata. + filename: z.string().min(1), contentType: z.string().min(1), - bytes: z.number().int().nonnegative().optional(), + bytes: z.number().int().nonnegative(), }) .strict(); diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index f41428bd1f..a562502174 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -306,9 +306,10 @@ const structuredConversationEventDataSchema = z const deliveredAttachmentSchema = z .object({ id: z.string().min(1), - name: z.string().min(1), + // Match storage + attachment route metadata (public report field). + filename: z.string().min(1), contentType: z.string().min(1), - bytes: z.number().int().nonnegative().optional(), + bytes: z.number().int().nonnegative(), }) .strict(); diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 76836e63cd..ff14276da2 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -873,10 +873,10 @@ function attachmentsDeliveredIdempotencyKey(args: { /** Record files delivered for humans without adding them to Pi replay. */ export async function recordAttachmentsDelivered(args: { attachments: Array<{ - bytes?: number; + bytes: number; contentType: string; + filename: string; id: string; - name: string; }>; conversationId: string; createdAtMs?: number; @@ -897,10 +897,11 @@ export async function recordAttachmentsDelivered(args: { type: "attachments_delivered", attachments: args.attachments.map((attachment) => ({ id: attachment.id, - name: attachment.name, + filename: attachment.filename, contentType: attachment.contentType, - ...(attachment.bytes !== undefined ? { bytes: attachment.bytes } : {}), + bytes: attachment.bytes, })), + // toolCallId/turnId are write-path only; report projection strips them. ...(args.toolCallId ? { toolCallId: args.toolCallId } : {}), ...(args.turnId ? { turnId: args.turnId } : {}), }, diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index e71c6a4416..63086c904a 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -36,10 +36,10 @@ const sendFilesResultSchema = juniorToolOutputSchema.extend({ type SendFilesResult = z.output; type DeliveredAttachment = { - bytes?: number; + bytes: number; contentType: string; + filename: string; id: string; - name: string; }; /** Operation cache keeps delivery metadata so retries can re-record safely. */ @@ -164,16 +164,17 @@ export function createSendFilesTool( const file = materializedFiles[index]!; return { id: attachment.id, - name: file.filename, + filename: file.filename, contentType: file.mimeType, bytes: file.bytes, }; }, ); const response: SendFilesResult = { + // Tool result stays minimal; transcript/report carries full metadata. attachment_refs: delivered.map((attachment) => ({ id: attachment.id, - name: attachment.name, + name: attachment.filename, })), }; // Cache before host bookkeeping so a later event-write failure cannot diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index 0f4e92c2e5..b2d06cda4a 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -383,7 +383,7 @@ describe("Slack sendFiles", () => { attachments: [ { id: rows[0]?.id, - name: "report.txt", + filename: "report.txt", contentType: "text/plain", bytes: Buffer.byteLength("report body"), }, @@ -465,7 +465,7 @@ describe("Slack sendFiles", () => { attachments: [ { id: first.attachment_refs[0]?.id, - name: "report.txt", + filename: "report.txt", contentType: "text/plain", bytes: Buffer.byteLength("report body"), }, diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 5998cf74ff..5e3b78bf5a 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -236,7 +236,7 @@ describe("conversation report event projection", () => { attachments: [ { id: "att-1", - name: "chart.png", + filename: "chart.png", contentType: "image/png", bytes: 18211, }, @@ -259,7 +259,7 @@ describe("conversation report event projection", () => { attachments: [ { id: "att-1", - name: "chart.png", + filename: "chart.png", contentType: "image/png", bytes: 18211, }, From 07efb7368373157cab7cc8394aeda2488ce3f732 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:11:54 +0000 Subject: [PATCH 9/9] fix(attachments): Align attachment public contracts with policy Use one filename noun across tool results, delivery events, and report schemas. Keep write-path ids off the public report surface, document the durable idempotency key, and serve mock chart bytes from a fixture module. Co-Authored-By: David Cramer --- .../src/mock-reporting/chart-png.ts | 5 +++ .../src/mock-reporting/routes.ts | 35 +++++++++---------- .../junior/src/api/conversations/events.ts | 1 - .../junior/src/api/schema/conversation.ts | 2 +- .../junior/src/chat/conversations/history.ts | 2 +- .../src/chat/conversations/projection.ts | 2 +- .../junior/src/chat/slack/tools/send-files.ts | 5 +-- .../integration/slack-send-files.test.ts | 10 +++--- .../unit/api/conversation-events.test.ts | 1 - .../junior/tests/unit/turn-result.test.ts | 2 +- 10 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 packages/junior-dashboard/src/mock-reporting/chart-png.ts diff --git a/packages/junior-dashboard/src/mock-reporting/chart-png.ts b/packages/junior-dashboard/src/mock-reporting/chart-png.ts new file mode 100644 index 0000000000..adb3119d55 --- /dev/null +++ b/packages/junior-dashboard/src/mock-reporting/chart-png.ts @@ -0,0 +1,5 @@ +/** Fixed 640x360 PNG for mock attachment previews. */ +export const mockChartPng = Buffer.from( + "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", + "base64", +); diff --git a/packages/junior-dashboard/src/mock-reporting/routes.ts b/packages/junior-dashboard/src/mock-reporting/routes.ts index 6b02cbe40e..13f4523436 100644 --- a/packages/junior-dashboard/src/mock-reporting/routes.ts +++ b/packages/junior-dashboard/src/mock-reporting/routes.ts @@ -23,6 +23,7 @@ import { taskParamsSchema, taskRunListSchema, } from "@sentry/junior/api/schema"; +import { mockChartPng } from "./chart-png"; import { readMockConversationDetail, readMockConversationEvents, @@ -144,7 +145,7 @@ export function createMockReportingApi(): Hono<{ ? jsonResponse(conversationDetailReportSchema, report) : errorResponse("Conversation not found.", 404); }); - // Tiny fixed bodies so dashboard mock can exercise image/file attachment cards. + // Fixed bodies so dashboard mock can exercise image/file attachment cards. app.get("/conversations/:conversationId/attachments/:attachmentId", (c) => { const conversationId = c.req.param("conversationId"); const attachmentId = c.req.param("attachmentId"); @@ -154,30 +155,28 @@ export function createMockReportingApi(): Hono<{ if (!readMockConversationDetail(conversationId)) { return errorResponse("Conversation not found.", 404); } - if (attachmentId.endsWith("-png") || attachmentId.includes("png")) { - // 640x360 mock chart PNG so image cards exercise natural aspect preview. - const png = Buffer.from( - "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", - "base64", - ); - return new Response(png, { + if (attachmentId === "qa-chart-png") { + return new Response(mockChartPng, { headers: { "cache-control": "private, no-store", "content-disposition": 'inline; filename="chart.png"', "content-type": "image/png", - "content-length": String(png.byteLength), + "content-length": String(mockChartPng.byteLength), }, }); } - const body = "mock attachment notes\n"; - return new Response(body, { - headers: { - "cache-control": "private, no-store", - "content-disposition": 'attachment; filename="notes.txt"', - "content-type": "text/plain", - "content-length": String(Buffer.byteLength(body)), - }, - }); + if (attachmentId === "qa-notes-txt") { + const body = "mock attachment notes\n"; + return new Response(body, { + headers: { + "cache-control": "private, no-store", + "content-disposition": 'attachment; filename="notes.txt"', + "content-type": "text/plain", + "content-length": String(Buffer.byteLength(body)), + }, + }); + } + return errorResponse("Attachment not found.", 404); }); app.get("/tasks", () => jsonResponse(taskListSchema, readMockTaskList())); app.get("/tasks/runs", () => { diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 63bb4b3f63..1068abf70b 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -365,7 +365,6 @@ function reportEventData(args: { } return { type: "attachments_delivered", - // toolCallId/turnId stay on the stored event for write idempotency only. attachments: data.attachments, }; case "turn_routed": diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index d144033db7..dc3e218f59 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -440,10 +440,10 @@ const conversationReportStructuredEventDataSchema = z }) .strict(); +/** Public attachment metadata on conversation reports and transcript media. */ const conversationReportDeliveredAttachmentSchema = z .object({ id: z.string().min(1), - // Stable public fields: same names as attachment storage metadata. filename: z.string().min(1), contentType: z.string().min(1), bytes: z.number().int().nonnegative(), diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index a562502174..fe007a97d0 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -303,10 +303,10 @@ const structuredConversationEventDataSchema = z }) .strict(); +/** Durable attachment metadata on host-owned delivery events. */ const deliveredAttachmentSchema = z .object({ id: z.string().min(1), - // Match storage + attachment route metadata (public report field). filename: z.string().min(1), contentType: z.string().min(1), bytes: z.number().int().nonnegative(), diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index ff14276da2..8ee22b4915 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -843,6 +843,7 @@ export async function recordToolExecutionStarted(args: { ]); } +/** Stable write key so retries do not mint duplicate delivery rows. */ function attachmentsDeliveredIdempotencyKey(args: { attachments: Array<{ id: string }>; conversationId: string; @@ -901,7 +902,6 @@ export async function recordAttachmentsDelivered(args: { contentType: attachment.contentType, bytes: attachment.bytes, })), - // toolCallId/turnId are write-path only; report projection strips them. ...(args.toolCallId ? { toolCallId: args.toolCallId } : {}), ...(args.turnId ? { turnId: args.turnId } : {}), }, diff --git a/packages/junior/src/chat/slack/tools/send-files.ts b/packages/junior/src/chat/slack/tools/send-files.ts index 63086c904a..facc986a6d 100644 --- a/packages/junior/src/chat/slack/tools/send-files.ts +++ b/packages/junior/src/chat/slack/tools/send-files.ts @@ -28,7 +28,8 @@ const sendFilesResultSchema = juniorToolOutputSchema.extend({ attachment_refs: z.array( z.object({ id: z.string().min(1), - name: z.string().min(1), + // Same noun as storage, delivery events, and the report API. + filename: z.string().min(1), }), ), }); @@ -174,7 +175,7 @@ export function createSendFilesTool( // Tool result stays minimal; transcript/report carries full metadata. attachment_refs: delivered.map((attachment) => ({ id: attachment.id, - name: attachment.filename, + filename: attachment.filename, })), }; // Cache before host bookkeeping so a later event-write failure cannot diff --git a/packages/junior/tests/integration/slack-send-files.test.ts b/packages/junior/tests/integration/slack-send-files.test.ts index b2d06cda4a..5a02030403 100644 --- a/packages/junior/tests/integration/slack-send-files.test.ts +++ b/packages/junior/tests/integration/slack-send-files.test.ts @@ -354,10 +354,10 @@ describe("Slack sendFiles", () => { const rows = await getSqlExecutor().db().select().from(juniorAttachments); expect(result.attachment_refs).toEqual([ - { id: rows[0]?.id, name: "report.txt" }, + { id: rows[0]?.id, filename: "report.txt" }, ]); expect(retry.attachment_refs).toEqual([ - { id: rows[0]?.id, name: "report.txt" }, + { id: rows[0]?.id, filename: "report.txt" }, ]); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ @@ -443,7 +443,7 @@ describe("Slack sendFiles", () => { ); expect(first.attachment_refs).toEqual([ - { id: expect.any(String), name: "report.txt" }, + { id: expect.any(String), filename: "report.txt" }, ]); expect(second).toMatchObject({ deduplicated: true, @@ -514,7 +514,7 @@ describe("Slack sendFiles", () => { }); const attachmentId = first.attachment_refs[0]?.id; expect(first.attachment_refs).toEqual([ - { id: expect.any(String), name: "report.txt" }, + { id: expect.any(String), filename: "report.txt" }, ]); await getSqlExecutor() @@ -541,7 +541,7 @@ describe("Slack sendFiles", () => { const rows = await getSqlExecutor().db().select().from(juniorAttachments); expect(retry.attachment_refs).toEqual([ - { id: attachmentId, name: "report.txt" }, + { id: attachmentId, filename: "report.txt" }, ]); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 5e3b78bf5a..0bb369d3ac 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -241,7 +241,6 @@ describe("conversation report event projection", () => { bytes: 18211, }, ], - // Stored for write-path idempotency; not part of the report surface. toolCallId: "call-send-1", }); diff --git a/packages/junior/tests/unit/turn-result.test.ts b/packages/junior/tests/unit/turn-result.test.ts index 60fff407a7..2789a78809 100644 --- a/packages/junior/tests/unit/turn-result.test.ts +++ b/packages/junior/tests/unit/turn-result.test.ts @@ -327,7 +327,7 @@ describe("buildTurnResult", () => { isError: false, content: [{ type: "text", text: "uploaded file" }], details: { - attachment_refs: [{ id: "att-1", name: "chart.png" }], + attachment_refs: [{ id: "att-1", filename: "chart.png" }], }, }, {