From 9962f57ad0b1b591971826bb8f48f02fb9bc79e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 20:56:51 -0800 Subject: [PATCH] Preserve empty attachment updates in projection pipeline - Treat `attachments: []` as an explicit update instead of dropping the field - Add regression test covering attachment clearing for an existing thread message --- .../Layers/ProjectionPipeline.test.ts | 122 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 4 +- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b929cbb5cb2e79f1f9b4d23bf95ae4a86cc0e277..8ca77624cd8ce5caab104e5cd3b5035f347dc7da 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -358,6 +358,128 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect( + "passes explicit empty attachment arrays through the projection pipeline to clear attachments", + () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const later = new Date(Date.now() + 1_000).toISOString(); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-clear-attachments-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-clear-attachments"), + title: "Project Clear Attachments", + workspaceRoot: "/tmp/project-clear-attachments", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-clear-attachments-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + projectId: ProjectId.makeUnsafe("project-clear-attachments"), + title: "Thread Clear Attachments", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-clear-attachments-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + messageId: MessageId.makeUnsafe("message-clear-attachments"), + role: "user", + text: "Has attachments", + attachments: [ + { + type: "image", + name: "clear.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-clear-attachments-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + messageId: MessageId.makeUnsafe("message-clear-attachments"), + role: "user", + text: "", + attachments: [], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-clear-attachments' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); + }), + ); + it.effect("does not persist attachment files when projector transaction rolls back", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8dbdfccc64249e42a19e29966d9a17a9ab755b52..162468b18b0947c4a517aecfb63b46be4a8e8590 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -623,9 +623,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { turnId: event.payload.turnId, role: event.payload.role, text: nextText, - ...(nextAttachments && nextAttachments.length > 0 - ? { attachments: [...nextAttachments] } - : {}), + ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, createdAt: existingMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, -- 2.54.0