fix(storage): stop persisting base64 image bytes on thread parts

Merged
PE
2mo ago
+119-4

ELEC-244.

What

Thread thrd_7xfnXnNkBxn4eeDaNZc0M (electrolux) has three tool_result parts of 130–236 KB each — tool-Read on a .png, with the whole image inlined as {type:"image", source:{type:"base64", data:"iVBORw0…"}}. Across prod: 686 parts, 54 MB of base64 in thread_message_parts.

It is written once and never read back as an image, but it is folded into the message history of every subsequent turn on the thread.

Fix

One guard at serializePayload in apps/api/src/storage/thread-message-parts.ts — the same choke point that already strips NULs, lone surrogates, URL userinfo and GitHub tokens, and the one no new producer can bypass:

  • an image block carrying inline bytes (source.data or data) becomes {type:"text", text:"[image omitted]"} — a text block rather than an image block with gutted data, so a folded message stays a valid content block;
  • a data:<mime>;base64,… URL inside any string is replaced with [base64 data omitted];
  • image blocks that only reference storage (studio-storage:, signed URLs) are left alone — cheap, and the UI needs them.

Live streaming is unaffected; the image still reaches the UI during the run, it just is not durable.

Testing

bun test apps/api/src/storage/thread-message-parts.test.ts — 18 pass, three new cases (inline block redacted, reference block preserved, data URL in text redacted). bun run check clean.

Not in this PR

Backfill of the existing 54 MB — that is a prod write, say the word and I will open it separately.


Summary by cubic

Stops persisting base64 image bytes in thread message parts and backfills the ones already stored — up to 236 KB per part that was otherwise folded into the prompt of every later turn.

  • At serialization time, inline image blocks become an [image omitted] text placeholder and base64 data URLs inside strings are redacted; image blocks that only reference storage stay intact.
  • Addresses ELEC-244.

Migration

  • Migration 202-redact-base64-thread-parts rewrites existing rows with the same serializePayload pass, skipping rows that serialize unchanged, so it is idempotent.
  • No down migration — the image bytes are not recoverable.