diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..dc06946bfb0 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -198,6 +198,32 @@ describe("AssetAccess", () => { expect(yield* resolveAsset(token, "ignored.png")).toEqual({ kind: "file", path: attachmentPath, + download: false, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("marks non-image attachment capabilities for download", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.pdf")).toEqual({ + kind: "file", + path: attachmentPath, + download: true, }); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..a01abd2c0b2 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -36,6 +36,7 @@ import { } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { SAFE_IMAGE_FILE_EXTENSIONS } from "../imageMime.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -91,7 +92,11 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -383,9 +388,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ), Effect.orElseSucceed(() => Option.none()), ); - return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) - : null; + if (!Option.isSome(info) || info.value.type !== "File") { + return null; + } + // Non-image attachments are downloaded rather than rendered inline so the + // same-origin asset route never interprets uploaded content (e.g. HTML/XML). + const extension = attachmentPath.slice(attachmentPath.lastIndexOf(".")).toLowerCase(); + return { + kind: "file", + path: attachmentPath, + download: !SAFE_IMAGE_FILE_EXTENSIONS.has(extension), + } satisfies ResolvedAsset; } if (claims.kind === "project-favicon") { diff --git a/apps/server/src/attachmentPrompt.test.ts b/apps/server/src/attachmentPrompt.test.ts new file mode 100644 index 00000000000..2e5b8390b71 --- /dev/null +++ b/apps/server/src/attachmentPrompt.test.ts @@ -0,0 +1,97 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { appendFileAttachmentPromptText } from "./attachmentPrompt.ts"; + +const attachmentsDir = NodePath.join(NodePath.sep, "tmp", "t3-attachment-prompt"); + +describe("attachmentPrompt", () => { + it("appends a prompt line for file attachments", () => { + const result = appendFileAttachmentPromptText({ + text: "hello", + attachmentsDir, + attachments: [ + { + type: "file", + id: "thread-1-00000000-0000-4000-8000-000000000001", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 2048, + }, + ], + }); + const attachmentPath = NodePath.join( + attachmentsDir, + "thread-1-00000000-0000-4000-8000-000000000001.txt", + ); + expect(result).toBe( + `hello\n\n[Attached file: ${attachmentPath} (notes.txt, text/plain, 2.0 KB). Read it from disk when needed.]`, + ); + }); + + it("returns text unchanged when only image attachments are present", () => { + expect( + appendFileAttachmentPromptText({ + text: "hello", + attachmentsDir, + attachments: [ + { + type: "image", + id: "thread-1-00000000-0000-4000-8000-000000000002", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], + }), + ).toBe("hello"); + }); + + it("returns only the prompt lines when the text is empty", () => { + const result = appendFileAttachmentPromptText({ + text: "", + attachmentsDir, + attachments: [ + { + type: "file", + id: "thread-1-00000000-0000-4000-8000-000000000003", + name: "data.csv", + mimeType: "text/csv", + sizeBytes: 12, + }, + ], + }); + const attachmentPath = NodePath.join( + attachmentsDir, + "thread-1-00000000-0000-4000-8000-000000000003.csv", + ); + expect(result).toBe( + `[Attached file: ${attachmentPath} (data.csv, text/csv, 12 B). Read it from disk when needed.]`, + ); + }); + + it("strips control characters, brackets, and parentheses from attachment names", () => { + const result = appendFileAttachmentPromptText({ + text: "hello", + attachmentsDir, + attachments: [ + { + type: "file", + id: "thread-1-00000000-0000-4000-8000-000000000004", + name: "a(b)\u0001[c].txt", + mimeType: "text/plain", + sizeBytes: 5, + }, + ], + }); + const attachmentPath = NodePath.join( + attachmentsDir, + "thread-1-00000000-0000-4000-8000-000000000004.txt", + ); + expect(result).toBe( + `hello\n\n[Attached file: ${attachmentPath} (a b c .txt, text/plain, 5 B). Read it from disk when needed.]`, + ); + }); +}); diff --git a/apps/server/src/attachmentPrompt.ts b/apps/server/src/attachmentPrompt.ts new file mode 100644 index 00000000000..4f21a516a9a --- /dev/null +++ b/apps/server/src/attachmentPrompt.ts @@ -0,0 +1,55 @@ +import type { ChatAttachment } from "@t3tools/contracts"; + +import { resolveAttachmentPath } from "./attachmentStore.ts"; + +const SIZE_LABEL_UNITS = ["B", "KB", "MB"] as const; + +function formatSizeLabel(sizeBytes: number): string { + let value = sizeBytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < SIZE_LABEL_UNITS.length - 1) { + value /= 1024; + unitIndex += 1; + } + const rounded = unitIndex === 0 ? String(value) : value.toFixed(1); + return `${rounded} ${SIZE_LABEL_UNITS[unitIndex]}`; +} + +function sanitizePromptFileName(name: string): string { + // eslint-disable-next-line no-control-regex + return name.replace(/[\u0000-\u001f\u007f()[\]]+/gu, " ").trim(); +} + +/** + * Non-image attachments are delivered by reference: the file already lives on + * the server's disk, so the provider agent reads it with its own tools. Images + * keep their native content-block path and are not mentioned here. + */ +export function appendFileAttachmentPromptText(input: { + readonly text: string; + readonly attachmentsDir: string; + readonly attachments: ReadonlyArray; +}): string { + const lines: Array = []; + for (const attachment of input.attachments) { + if (attachment.type !== "file") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: input.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + continue; + } + const name = sanitizePromptFileName(attachment.name); + lines.push( + `[Attached file: ${attachmentPath} (${name}, ${attachment.mimeType}, ${formatSizeLabel(attachment.sizeBytes)}). Read it from disk when needed.]`, + ); + } + if (lines.length === 0) { + return input.text; + } + const joined = lines.join("\n"); + return input.text.trim().length > 0 ? `${input.text}\n\n${joined}` : joined; +} diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf..94915650f9c 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + attachmentRelativePath, createAttachmentId, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, @@ -44,6 +45,37 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); }); + it("builds file attachment relative paths with the inferred file extension", () => { + expect( + attachmentRelativePath({ + type: "file", + id: "thread-1-00000000-0000-4000-8000-000000000001", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }), + ).toBe("thread-1-00000000-0000-4000-8000-000000000001.pdf"); + }); + + it("resolves attachment path by id for a written file attachment extension", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), + ); + try { + const attachmentId = "thread-1-attachment"; + const pdfPath = NodePath.join(attachmentsDir, `${attachmentId}.pdf`); + NodeFS.writeFileSync(pdfPath, Buffer.from("hello")); + + const resolved = resolveAttachmentPathById({ + attachmentsDir, + attachmentId, + }); + expect(resolved).toBe(pdfPath); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..857e39d3224 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -8,9 +8,16 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; -import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; +import { + inferFileExtension, + inferImageExtension, + SAFE_FILE_EXTENSIONS, + SAFE_IMAGE_FILE_EXTENSIONS, +} from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ...SAFE_FILE_EXTENSIONS, ".bin"]), +]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -63,6 +70,13 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": { + const extension = inferFileExtension({ + mimeType: attachment.mimeType, + fileName: attachment.name, + }); + return `${attachment.id}${extension}`; + } } } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b9bb40f372d..be58bee4327 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -197,6 +197,11 @@ export const assetRouteLayer = HttpRouter.add( headers: { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", + ...(asset.download + ? { + "Content-Disposition": `attachment; filename="${asset.path.replace(/^.*[/\\]/, "")}"`, + } + : {}), }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index cc1cc2ff776..5fe309907ae 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; +import { inferFileExtension, inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; describe("imageMime", () => { it("parses base64 data URL with mime type", () => { @@ -35,4 +35,39 @@ describe("imageMime", () => { it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); + + it("infers file extension from an allowlisted file name extension", () => { + expect( + inferFileExtension({ mimeType: "application/octet-stream", fileName: "report.PDF" }), + ).toBe(".pdf"); + }); + + it("infers file extension from the last segment of a multi-dot file name", () => { + expect(inferFileExtension({ mimeType: "application/octet-stream", fileName: "a.b.csv" })).toBe( + ".csv", + ); + }); + + it("falls back to an allowlisted mime extension when the file name extension is unsafe", () => { + expect(inferFileExtension({ mimeType: "application/pdf", fileName: "notes.exe" })).toBe(".pdf"); + }); + + it("falls back to an allowlisted mime extension when the file name has no extension", () => { + expect(inferFileExtension({ mimeType: "text/plain", fileName: "notes" })).toBe(".txt"); + }); + + it("defaults file extension to .bin when neither file name nor mime is allowlisted", () => { + expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: "payload.exe" })).toBe( + ".bin", + ); + expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: "notes" })).toBe( + ".bin", + ); + }); + + it("defaults dotfile names without an allowlisted mime to .bin", () => { + expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: ".env" })).toBe( + ".bin", + ); + }); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abe..ae9a3ef1829 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -57,6 +57,48 @@ export function parseBase64DataUrl( return { mimeType, base64 }; } +export const SAFE_FILE_EXTENSIONS = new Set([ + ".csv", + ".diff", + ".docx", + ".gz", + ".json", + ".jsonl", + ".log", + ".md", + ".patch", + ".pdf", + ".pptx", + ".tar", + ".toml", + ".tsv", + ".txt", + ".xlsx", + ".xml", + ".yaml", + ".yml", + ".zip", +]); + +export function inferFileExtension(input: { mimeType: string; fileName?: string }): string { + const fileName = input.fileName?.trim() ?? ""; + const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); + const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; + if (SAFE_FILE_EXTENSIONS.has(fileNameExtension)) { + return fileNameExtension; + } + + // Mime.getExtension returns dot-less extensions ("pdf"), the sets hold + // dotted ones (".pdf"). + const fromMimeExtension = Mime.getExtension(input.mimeType); + const dottedMimeExtension = fromMimeExtension ? `.${fromMimeExtension}` : null; + if (dottedMimeExtension && SAFE_FILE_EXTENSIONS.has(dottedMimeExtension)) { + return dottedMimeExtension; + } + + return ".bin"; +} + export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { const key = input.mimeType.toLowerCase(); const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..922ab42106b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -928,6 +928,214 @@ it.layer( ); }); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-file-gc-")), +)("OrchestrationProjectionPipeline", (it) => { + it.effect("keeps referenced file attachments and prunes orphans when a thread is reverted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("Thread Revert.Docs"); + const keepAttachmentId = "thread-revert-docs-00000000-0000-4000-8000-000000000001"; + const removeAttachmentId = "thread-revert-docs-00000000-0000-4000-8000-000000000002"; + const orphanAttachmentId = "thread-revert-docs-00000000-0000-4000-8000-000000000003"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-revert-docs-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-revert-docs"), + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-revert-docs"), + title: "Project Revert Docs", + workspaceRoot: "/tmp/project-revert-docs", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-revert-docs-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-revert-docs"), + title: "Thread Revert Docs", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.make("evt-revert-docs-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-3"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.make("turn-keep"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-docs/turn/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("message-keep"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-revert-docs-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-4"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-keep"), + role: "assistant", + text: "Keep", + attachments: [ + { + type: "file", + id: keepAttachmentId, + name: "keep.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, + ], + turnId: TurnId.make("turn-keep"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.make("evt-revert-docs-5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-5"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.make("turn-remove"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-docs/turn/2"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("message-remove"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-revert-docs-6"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-6"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-6"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-remove"), + role: "assistant", + text: "Remove", + attachments: [ + { + type: "file", + id: removeAttachmentId, + name: "remove.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, + ], + turnId: TurnId.make("turn-remove"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.pdf`); + const removePath = path.join(attachmentsDir, `${removeAttachmentId}.pdf`); + const orphanPath = path.join(attachmentsDir, `${orphanAttachmentId}.pdf`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(removePath, "remove"); + yield* fileSystem.writeFileString(orphanPath, "orphan"); + assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(removePath)); + assert.isTrue(yield* exists(orphanPath)); + + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.make("evt-revert-docs-7"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-docs-7"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-docs-7"), + metadata: {}, + payload: { + threadId, + turnCount: 1, + }, + }); + + assert.isTrue(yield* exists(keepPath)); + assert.isFalse(yield* exists(removePath)); + assert.isFalse(yield* exists(orphanPath)); + }), + ); +}); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..7b58620eb25 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -338,9 +338,6 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..2d57cf109cb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -466,6 +466,58 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect( + "sends only image attachments to the provider and appends file attachment prompt text", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const imageAttachment = { + type: "image" as const, + id: "thread-1-00000000-0000-4000-8000-000000000001", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const fileAttachment = { + type: "file" as const, + id: "thread-1-00000000-0000-4000-8000-000000000002", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + }; + const attachmentsDir = NodePath.join(harness.stateDir, "attachments"); + const fileAttachmentPath = NodePath.join(attachmentsDir, `${fileAttachment.id}.txt`); + NodeFS.mkdirSync(attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(fileAttachmentPath, "notes"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-file-attachments"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-file-attachments"), + role: "user", + text: "hello with attachments", + attachments: [imageAttachment, fileAttachment], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const request = harness.sendTurn.mock.calls[0]?.[0] as { + readonly input?: string; + readonly attachments?: ReadonlyArray; + }; + expect(request.attachments).toEqual([imageAttachment]); + expect(request.input).toBe( + `hello with attachments\n\n[Attached file: ${fileAttachmentPath} (notes.txt, text/plain, 5 B). Read it from disk when needed.]`, + ); + }), + ); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..d31cea6a9ec 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -25,7 +25,9 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { appendFileAttachmentPromptText } from "../../attachmentPrompt.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { ServerConfig } from "../../config.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; @@ -196,6 +198,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -605,8 +608,18 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); - const normalizedAttachments = input.attachments ?? []; + const normalizedInput = toNonEmptyProviderInput( + appendFileAttachmentPromptText({ + text: input.messageText, + attachmentsDir: serverConfig.attachmentsDir, + attachments: input.attachments ?? [], + }), + ); + // Only image attachments go to the provider as content blocks; file + // attachments are delivered by path in the prompt text above. + const normalizedAttachments = (input.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); const activeSession = yield* providerService .listSessions() .pipe( diff --git a/apps/server/src/orchestration/Normalizer.test.ts b/apps/server/src/orchestration/Normalizer.test.ts new file mode 100644 index 00000000000..c5dfdbbf31f --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + ThreadId, + type ClientOrchestrationCommand, + type UploadChatAttachment, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { normalizeDispatchCommand } from "./Normalizer.ts"; + +const testLayer = Layer.mergeAll( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-test-" }), + WorkspacePaths.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); + +const now = "2026-01-01T00:00:00.000Z"; + +const turnStartCommand = ( + attachments: ReadonlyArray, +): ClientOrchestrationCommand => ({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("user-message-1"), + role: "user", + text: "hello normalizer", + attachments, + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, +}); + +const asDataUrl = (mimeType: string, bytes: Buffer) => + `data:${mimeType};base64,${bytes.toString("base64")}`; + +describe("Normalizer", () => { + it.effect("persists file attachments and writes their bytes to the attachments dir", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("hello world"); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand([ + { + type: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: bytes.byteLength, + dataUrl: asDataUrl("text/plain", bytes), + }, + ]), + ); + + expect(normalized.type).toBe("thread.turn.start"); + if (normalized.type !== "thread.turn.start") { + return; + } + const attachment = normalized.message.attachments[0]; + expect(attachment).toMatchObject({ + type: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: bytes.byteLength, + }); + expect(attachment?.id).toMatch( + /^thread-1-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + if (!attachment) { + return; + } + + const attachmentPath = path.join(attachmentsDir, `${attachment.id}.txt`); + expect(yield* fileSystem.readFileString(attachmentPath)).toBe("hello world"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects image attachments with a non-image data URL mime type", () => + Effect.gen(function* () { + const error = yield* normalizeDispatchCommand( + turnStartCommand([ + { + type: "image", + name: "fake.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: asDataUrl("text/plain", Buffer.from("hello")), + }, + ]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("OrchestrationDispatchCommandError"); + expect(error.message).toBe("Invalid image attachment payload for 'fake.png'."); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects file attachments over the maximum attachment size", () => + Effect.gen(function* () { + const bytes = Buffer.alloc(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, "a"); + const error = yield* normalizeDispatchCommand( + turnStartCommand([ + { + type: "file", + name: "big.txt", + mimeType: "text/plain", + sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + dataUrl: asDataUrl("text/plain", bytes), + }, + ]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("OrchestrationDispatchCommandError"); + expect(error.message).toBe("Attachment 'big.txt' is empty or too large."); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bed166eba45..b880f5392ed 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -74,16 +74,16 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + if (!parsed || (attachment.type === "image" && !parsed.mimeType.startsWith("image/"))) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid ${attachment.type} attachment payload for '${attachment.name}'.`, }); } const bytes = Buffer.from(parsed.base64, "base64"); if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `Attachment '${attachment.name}' is empty or too large.`, }); } @@ -95,7 +95,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } const persistedAttachment = { - type: "image" as const, + type: attachment.type, id: attachmentId, name: attachment.name, mimeType: parsed.mimeType.toLowerCase(), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..6f16768b74c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -10,7 +10,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -193,9 +193,7 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { +export function cloneComposerImageForRetry(image: ComposerAttachment): ComposerAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..acae9a6fad7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -160,7 +160,7 @@ import { } from "../logicalProject"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, @@ -250,8 +250,8 @@ import { } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached file(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1092,7 +1092,7 @@ function ChatViewContent(props: ChatViewProps) { : null, ); const promptRef = useRef(""); - const composerImagesRef = useRef([]); + const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -4007,11 +4007,11 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, + type: image.type, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, @@ -4019,7 +4019,7 @@ function ChatViewContent(props: ChatViewProps) { })), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -4081,7 +4081,7 @@ function ChatViewContent(props: ChatViewProps) { let titleSeed = trimmed; if (!titleSeed) { if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; + titleSeed = `${composerImagesSnapshot[0]?.type === "file" ? "File" : "Image"}: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..4f8eb530315 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,7 +44,7 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftId, type PersistedComposerImageAttachment, useComposerDraftStore, @@ -95,6 +95,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + FileTextIcon, ListTodoIcon, PencilRulerIcon, type LucideIcon, @@ -124,6 +125,7 @@ import { import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { formatAttachmentSizeLabel } from "../../lib/attachmentSize"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -410,7 +412,7 @@ export interface ChatComposerHandle { /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; - images: ComposerImageAttachment[]; + images: ComposerAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -499,7 +501,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; - composerImagesRef: React.RefObject; + composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -1151,14 +1153,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { + (image: ComposerAttachment) => { addComposerDraftImage(composerDraftTarget, image); }, [composerDraftTarget, addComposerDraftImage], ); const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { + (images: ComposerAttachment[]) => { addComposerDraftImages(composerDraftTarget, images); }, [composerDraftTarget, addComposerDraftImages], @@ -1368,6 +1370,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -1765,32 +1768,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } - const nextImages: ComposerImageAttachment[] = []; + const nextImages: ComposerAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } + const isImage = file.type.startsWith("image/"); const previewUrl = URL.createObjectURL(file); nextImages.push({ - type: "image", + type: isImage ? "image" : "file", id: randomUUID(), - name: file.name || "image", - mimeType: file.type, + name: file.name || (isImage ? "image" : "file"), + mimeType: file.type || "application/octet-stream", sizeBytes: file.size, previewUrl, file, @@ -1815,10 +1815,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + addComposerImages(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2269,7 +2267,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) } onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); + const preview = buildExpandedImagePreview( + composerImages.filter((attachment) => attachment.type === "image"), + imageId, + ); if (preview) onExpandImage(preview); }} className="mb-3" @@ -2320,15 +2321,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .map((image) => (
- {image.previewUrl ? ( + {image.type === "file" ? ( + <> + +
+
+ {image.name} +
+
+ {formatAttachmentSizeLabel(image.sizeBytes)} +
+
+ + ) : image.previewUrl ? (