| ... | @@ -358,6 +358,128 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -358,6 +358,128 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 358 | }), | 358 | }), |
| 359 | ); | 359 | ); |
| 360 | | 360 | |
| | 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 | |
| 361 | it.effect("does not persist attachment files when projector transaction rolls back", () => | 483 | it.effect("does not persist attachment files when projector transaction rolls back", () => |
| 362 | Effect.gen(function* () { | 484 | Effect.gen(function* () { |
| 363 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 485 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |