authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 20:56:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 21:30:46-08:00
log9962f57ad0b1b591971826bb8f48f02fb9bc79e7
tree3bd01da6d7845079ee2dd65fb9fdbe65e9ac9ec9
parent8b538e28021f73da137c8d83117f9cb391a6ec0b

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

2 files changed, 123 insertions(+), 3 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+122
......@@ -358,6 +358,128 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
358358 }),
359359 );
360360
361 it.effect(
362 "passes explicit empty attachment arrays through the projection pipeline to clear attachments",
363 () =>
364 Effect.gen(function* () {
365 const projectionPipeline = yield* OrchestrationProjectionPipeline;
366 const eventStore = yield* OrchestrationEventStore;
367 const sql = yield* SqlClient.SqlClient;
368 const now = new Date().toISOString();
369 const later = new Date(Date.now() + 1_000).toISOString();
370
371 yield* eventStore.append({
372 type: "project.created",
373 eventId: EventId.makeUnsafe("evt-clear-attachments-1"),
374 aggregateKind: "project",
375 aggregateId: ProjectId.makeUnsafe("project-clear-attachments"),
376 occurredAt: now,
377 commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
378 causationEventId: null,
379 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
380 metadata: {},
381 payload: {
382 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
383 title: "Project Clear Attachments",
384 workspaceRoot: "/tmp/project-clear-attachments",
385 defaultModel: null,
386 scripts: [],
387 createdAt: now,
388 updatedAt: now,
389 },
390 });
391
392 yield* eventStore.append({
393 type: "thread.created",
394 eventId: EventId.makeUnsafe("evt-clear-attachments-2"),
395 aggregateKind: "thread",
396 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
397 occurredAt: now,
398 commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
399 causationEventId: null,
400 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
401 metadata: {},
402 payload: {
403 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
404 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
405 title: "Thread Clear Attachments",
406 model: "gpt-5-codex",
407 branch: null,
408 worktreePath: null,
409 createdAt: now,
410 updatedAt: now,
411 },
412 });
413
414 yield* eventStore.append({
415 type: "thread.message-sent",
416 eventId: EventId.makeUnsafe("evt-clear-attachments-3"),
417 aggregateKind: "thread",
418 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
419 occurredAt: now,
420 commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
421 causationEventId: null,
422 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
423 metadata: {},
424 payload: {
425 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
426 messageId: MessageId.makeUnsafe("message-clear-attachments"),
427 role: "user",
428 text: "Has attachments",
429 attachments: [
430 {
431 type: "image",
432 name: "clear.png",
433 mimeType: "image/png",
434 sizeBytes: 5,
435 dataUrl: "data:image/png;base64,SGVsbG8=",
436 },
437 ],
438 turnId: null,
439 streaming: false,
440 createdAt: now,
441 updatedAt: now,
442 },
443 });
444
445 yield* eventStore.append({
446 type: "thread.message-sent",
447 eventId: EventId.makeUnsafe("evt-clear-attachments-4"),
448 aggregateKind: "thread",
449 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
450 occurredAt: later,
451 commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
452 causationEventId: null,
453 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
454 metadata: {},
455 payload: {
456 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
457 messageId: MessageId.makeUnsafe("message-clear-attachments"),
458 role: "user",
459 text: "",
460 attachments: [],
461 turnId: null,
462 streaming: false,
463 createdAt: now,
464 updatedAt: later,
465 },
466 });
467
468 yield* projectionPipeline.bootstrap;
469
470 const rows = yield* sql<{
471 readonly attachmentsJson: string | null;
472 }>`
473 SELECT
474 attachments_json AS "attachmentsJson"
475 FROM projection_thread_messages
476 WHERE message_id = 'message-clear-attachments'
477 `;
478 assert.equal(rows.length, 1);
479 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []);
480 }),
481 );
482
361483 it.effect("does not persist attachment files when projector transaction rolls back", () =>
362484 Effect.gen(function* () {
363485 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1-3
......@@ -623,9 +623,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
623623 turnId: event.payload.turnId,
624624 role: event.payload.role,
625625 text: nextText,
626 ...(nextAttachments && nextAttachments.length > 0
627 ? { attachments: [...nextAttachments] }
628 : {}),
626 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
629627 isStreaming: event.payload.streaming,
630628 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
631629 updatedAt: event.payload.updatedAt,