From 797a8e040406eaf9b8bc1d4cf615b3a71d412065 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 15 Jun 2026 20:40:01 -0700 Subject: [PATCH] fix(attachments): preserve pdf/audio kind when reloading attachments from the DB _reconstruct_attachment_refs collapsed every non-image attachment to the 'document' placeholder kind, so a reloaded session's pdf/audio placeholder type ({type:document}) mismatched the live-injection type ({type:pdf}/{type:audio}). Harmless today (resolution keys on attachment_id + blob kind) but a latent footgun for any consumer branching on the pre-resolution placeholder type. Preserve image/pdf/audio verbatim; only a stored 'text' blob collapses to 'document'. --- tests/test_attachments_pdf_audio.py | 39 +++++++++++++++++++++++++++++ turnstone/core/storage/_utils.py | 14 +++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/tests/test_attachments_pdf_audio.py b/tests/test_attachments_pdf_audio.py index 069cd478..cebdcdf2 100644 --- a/tests/test_attachments_pdf_audio.py +++ b/tests/test_attachments_pdf_audio.py @@ -75,6 +75,45 @@ class TestSniffAudio: assert sniff_audio_mime(b"") is None +class TestReconstructAttachmentRefs: + def test_preserves_pdf_audio_kind_on_reload(self) -> None: + from turnstone.core.storage._utils import _reconstruct_attachment_refs + + atts = { + 1: [ + { + "attachment_id": "a1", + "kind": "pdf", + "filename": "r.pdf", + "mime_type": "application/pdf", + }, + { + "attachment_id": "a2", + "kind": "audio", + "filename": "a.wav", + "mime_type": "audio/wav", + }, + { + "attachment_id": "a3", + "kind": "image", + "filename": "i.png", + "mime_type": "image/png", + }, + { + "attachment_id": "a4", + "kind": "text", + "filename": "t.txt", + "mime_type": "text/plain", + }, + ] + } + refs, meta = _reconstruct_attachment_refs(atts, 1) + # pdf/audio/image kept verbatim; only 'text' collapses to 'document' + # (so the placeholder type can't collide with a real text content part). + assert [r.kind for r in refs] == ["pdf", "audio", "image", "document"] + assert [m["kind"] for m in meta] == ["pdf", "audio", "image", "text"] + + class TestSafeAttachmentLabel: def test_strips_frame_breakers(self) -> None: from turnstone.core.attachments import safe_attachment_label diff --git a/turnstone/core/storage/_utils.py b/turnstone/core/storage/_utils.py index d9aeafb3..b321103a 100644 --- a/turnstone/core/storage/_utils.py +++ b/turnstone/core/storage/_utils.py @@ -410,11 +410,15 @@ def _reconstruct_attachment_refs( if not attachments_by_msg or row_id is None: return refs, meta for att in attachments_by_msg.get(row_id, []): - # AttachmentRef.kind is the by-reference content kind ('image' | - # 'document'); the stored blob kind ('image' | 'text') drives the actual - # resolution. 'document' (not 'text') keeps the placeholder type from - # colliding with a real text content part on the dict round-trip. - ref_kind = "image" if str(att.get("kind") or "") == "image" else "document" + # AttachmentRef.kind is the by-reference placeholder kind: the stored + # blob kind verbatim for image / pdf / audio, else 'document' for a + # stored 'text' blob (so the placeholder type can't collide with a real + # text content part on the dict round-trip). The blob kind drives the + # actual resolution; preserving pdf / audio here keeps the reloaded + # placeholder type ({type:pdf} / {type:audio}) consistent with the live + # injection path, which already emits those. + kind_str = str(att.get("kind") or "") + ref_kind = kind_str if kind_str in ("image", "pdf", "audio") else "document" refs.append( AttachmentRef( attachment_id=str(att.get("attachment_id") or ""),