mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(attachments): AttachmentRef as the canonical by-reference content
Non-text content (user uploads, reloaded tool images) rides as AttachmentRef(id,kind) in the canonical Turn — session.messages carries ids, never bytes. Each output materializes it to inline data-URI/document parts by point-lookup on the content- addressed store: the wire (ChatSession._lower_messages_to_wire, in _full_messages), /history + export (reconstruct_messages resolves), and the per-turn token estimate (a by-ref image costs one fixed image budget; the doc char budget lands at send). reconstruct splits: reconstruct_turns = unresolved row→Turn (load_message_turns, the resume path); reconstruct_messages = resolved dict facade. RawContentBlock is demoted to the transient carrier for a resolved inline part on the dict↔Turn bridge.
This commit is contained in:
@@ -47,6 +47,15 @@ _ROUNDTRIP: list[dict[str, Any]] = [
|
||||
{"type": "text", "text": "[unreadable attachment: bad.bin]"},
|
||||
],
|
||||
},
|
||||
{
|
||||
# By-reference attachments: the canonical content form (id, never bytes).
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what's this?"},
|
||||
{"type": "image", "attachment_id": "sha256:abc"},
|
||||
{"type": "document", "attachment_id": "sha256:def"},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
{"role": "assistant", "content": ""}, # empty assistant (no text, no tools)
|
||||
{
|
||||
@@ -179,7 +188,44 @@ def test_empty_content_roundtrips_to_empty_string() -> None:
|
||||
assert turn_to_dict(Turn(Role.ASSISTANT)) == {"role": "assistant", "content": ""}
|
||||
|
||||
|
||||
def test_attachment_ref_emits_defensive_part() -> None:
|
||||
# AttachmentRef isn't produced pre-by-ref-wiring, but the emit path is defined.
|
||||
def test_attachment_ref_is_the_canonical_non_text_form() -> None:
|
||||
# By-reference placeholders ``{type: image|document, attachment_id}`` are the
|
||||
# canonical content; a real inline ``image_url`` (no id) stays a RawContentBlock.
|
||||
t = turn_from_dict(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "q"},
|
||||
{"type": "image", "attachment_id": "sha256:abc"},
|
||||
{"type": "document", "attachment_id": "sha256:def"},
|
||||
{"type": "image_url", "image_url": {"url": "data:..."}},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert isinstance(t.content[1], AttachmentRef) and t.content[1].attachment_id == "sha256:abc"
|
||||
assert isinstance(t.content[2], AttachmentRef) and t.content[2].kind == "document"
|
||||
assert isinstance(t.content[3], RawContentBlock) # resolved inline part, no id
|
||||
|
||||
|
||||
def test_attachment_ref_emits_placeholder() -> None:
|
||||
t = Turn(Role.USER, (AttachmentRef(attachment_id="abc", kind="image"),))
|
||||
assert turn_to_dict(t)["content"] == [{"type": "image", "attachment_id": "abc"}]
|
||||
|
||||
|
||||
def test_resolve_attachment_refs_materializes_and_drops_missing() -> None:
|
||||
from turnstone.core.trajectory import resolve_attachment_refs
|
||||
|
||||
turns = [
|
||||
Turn(
|
||||
Role.USER,
|
||||
(
|
||||
TextBlock("look"),
|
||||
AttachmentRef(attachment_id="a1", kind="image"),
|
||||
AttachmentRef(attachment_id="gone", kind="image"),
|
||||
),
|
||||
)
|
||||
]
|
||||
part = {"type": "image_url", "image_url": {"url": "data:img"}}
|
||||
out = turn_to_dict(resolve_attachment_refs(turns, {"a1": part})[0])
|
||||
# a1 → inline part; the pruned ref is dropped; text kept.
|
||||
assert out["content"] == [{"type": "text", "text": "look"}, part]
|
||||
|
||||
Reference in New Issue
Block a user