fix(#2866): persist LlmMessage.images / ImagePart in file-backed snap…#91
Merged
Conversation
…shot
Pre-#2866 `SnapshotJson.encodeMessage` serialised only role / content /
toolCalls per message. Any vision attachments on the conversation
(`LlmMessage.images: List<ImagePart>?` from #2470) were dropped on
encode and `decodeMessage` always rehydrated images = null. File-backed
snapshot/resume silently lost image context; SSE-style apps that
checkpointed mid-conversation could not resume with the original images
attached.
Fix in `core/Snapshot.kt`:
- `encodeMessage` now writes an `images` array when non-null, with
per-part `{base64, mime}`. Wire shape stays byte-identical for
messages without images (the `images` key is omitted), so pre-#2866
snapshots and current image-less calls produce the same JSON.
- `decodeMessage` rehydrates each `ImagePart` via a `decodeWireMime`
helper that maps the four shipped MIMEs (PNG / JPEG / GIF / WEBP) back
to the closed `WireMime` sealed type. Unknown MIMEs are skipped
defensively (a hand-edited or future-extended snapshot doesn't crash
resume — the unknown part drops, the rest of the message rehydrates).
Encoding choice: base64 directly inside the message rather than
`ContentRef.hash` + BlobStore re-fetch on resume. Self-contained
snapshots — no `BlobStore` dependency at restore time — match the
audit story better. Cost is the snapshot file size (~5KB per image vs.
~80 bytes for a ref), accepted as the right trade for the 0.6.x line.
Regression coverage in new `SnapshotImagesRoundTripTest`:
- single image round-trips byte-for-byte
- mixed-MIME ordered list round-trips
- no-image message back-compat (the `images` key is omitted)
- legacy pre-#2866 snapshot decodes with null images
- unknown-MIME entries skip on decode (defensive)
Full ./gradlew test + detekt green. detekt baseline regenerated to
include the new test file's package-naming entry (same as the other
agents_engine.* packages).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
…shot
Pre-#2866
SnapshotJson.encodeMessageserialised only role / content / toolCalls per message. Any vision attachments on the conversation (LlmMessage.images: List<ImagePart>?from #2470) were dropped on encode anddecodeMessagealways rehydrated images = null. File-backed snapshot/resume silently lost image context; SSE-style apps that checkpointed mid-conversation could not resume with the original images attached.Fix in
core/Snapshot.kt:encodeMessagenow writes animagesarray when non-null, with per-part{base64, mime}. Wire shape stays byte-identical for messages without images (theimageskey is omitted), so pre-#2866 snapshots and current image-less calls produce the same JSON.decodeMessagerehydrates eachImagePartvia adecodeWireMimehelper that maps the four shipped MIMEs (PNG / JPEG / GIF / WEBP) back to the closedWireMimesealed type. Unknown MIMEs are skipped defensively (a hand-edited or future-extended snapshot doesn't crash resume — the unknown part drops, the rest of the message rehydrates).Encoding choice: base64 directly inside the message rather than
ContentRef.hash+ BlobStore re-fetch on resume. Self-contained snapshots — noBlobStoredependency at restore time — match the audit story better. Cost is the snapshot file size (~5KB per image vs. ~80 bytes for a ref), accepted as the right trade for the 0.6.x line.Regression coverage in new
SnapshotImagesRoundTripTest:imageskey is omitted)Full ./gradlew test + detekt green. detekt baseline regenerated to include the new test file's package-naming entry (same as the other agents_engine.* packages).