Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@
<ID>PackageNaming:SkillSelectionTest.kt$package agents_engine.core</ID>
<ID>PackageNaming:SkillsEncapsulationTest.kt$package agents_engine.core</ID>
<ID>PackageNaming:Snapshot.kt$package agents_engine.core</ID>
<ID>PackageNaming:SnapshotImagesRoundTripTest.kt$package agents_engine.core</ID>
<ID>PackageNaming:SnapshotManifestGuardTest.kt$package agents_engine.core</ID>
<ID>PackageNaming:SnapshotResumeTest.kt$package agents_engine.model</ID>
<ID>PackageNaming:StdioMcpTransport.kt$package agents_engine.mcp</ID>
Expand Down
34 changes: 34 additions & 0 deletions src/main/kotlin/agents_engine/core/Snapshot.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agents_engine.core

import agents_engine.generation.LenientJsonParser
import agents_engine.model.ImagePart
import agents_engine.model.InlineToolCallParser
import agents_engine.model.LlmMessage
import agents_engine.model.TokenUsage
Expand Down Expand Up @@ -164,6 +165,19 @@ internal object SnapshotJson {
})
append("]")
}
// #2866 — persist vision attachments so file-backed resume can rehydrate
// the LlmMessage byte-for-byte. Pre-#2866 the encoder silently dropped
// `images`, breaking SSE-style apps that snapshot mid-conversation.
// Base64 directly inside the snapshot (rather than ContentRef.hash with
// a re-fetch on resume) keeps the snapshot self-contained — no
// BlobStore dependency at restore time.
m.images?.let { imgs ->
append(""","images":[""")
append(imgs.joinToString(",") { part ->
"""{"base64":${part.base64.toJsonString()},"mime":${part.wireMime.value.toJsonString()}}"""
})
append("]")
}
append("}")
}

Expand Down Expand Up @@ -194,13 +208,33 @@ internal object SnapshotJson {
.entries.associate { (k, v) -> k.toString() to v } as Map<String, Any?>
ToolCall(name = name, arguments = args)
}
// #2866 — rehydrate vision attachments. Mime strings that don't match
// a known [ImagePart.WireMime] variant skip with a null mapping
// (defensive — the encoder only writes the closed set, but a
// hand-edited or future-extended snapshot shouldn't crash resume).
val images = (m["images"] as? List<*>)?.mapNotNull { imgRaw ->
val img = imgRaw as? Map<*, *> ?: return@mapNotNull null
val base64 = img["base64"] as? String ?: return@mapNotNull null
val mimeStr = img["mime"] as? String ?: return@mapNotNull null
val wireMime = decodeWireMime(mimeStr) ?: return@mapNotNull null
ImagePart(base64 = base64, wireMime = wireMime)
}
return LlmMessage(
role = m["role"]?.toString() ?: "user",
content = m["content"]?.toString() ?: "",
toolCalls = toolCalls,
images = images,
)
}

private fun decodeWireMime(value: String): ImagePart.WireMime? = when (value) {
ImagePart.WireMime.Png.value -> ImagePart.WireMime.Png
ImagePart.WireMime.Jpeg.value -> ImagePart.WireMime.Jpeg
ImagePart.WireMime.Gif.value -> ImagePart.WireMime.Gif
ImagePart.WireMime.Webp.value -> ImagePart.WireMime.Webp
else -> null
}

private fun decodeTokens(t: Map<*, *>?): TokenUsage? {
if (t == null) return null
val prompt = (t["prompt"] as? Number)?.toInt() ?: return null
Expand Down
163 changes: 163 additions & 0 deletions src/test/kotlin/agents_engine/core/SnapshotImagesRoundTripTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package agents_engine.core

import agents_engine.model.ImagePart
import agents_engine.model.LlmMessage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
* #2866 — regression coverage for the SnapshotJson encoder + decoder
* round-tripping `LlmMessage.images`.
*
* Pre-#2866 the encoder serialised only `role / content / toolCalls`
* per message. File-backed `snapshot/resume` silently lost any vision
* attachments that were on the conversation when the snapshot was
* taken. SSE-style apps mid-conversation lost the image context on
* resume.
*
* The fix encodes each `ImagePart` as `{base64, mime}` directly inside
* the message (rather than ContentRef + BlobStore re-fetch) so the
* snapshot stays self-contained — no BlobStore dependency at resume.
*/
class SnapshotImagesRoundTripTest {

@Test
fun `single image round-trips through encode-decode`() {
val original = SessionSnapshot(
messages = listOf(
LlmMessage(
role = "user",
content = "What's in this image?",
images = listOf(
ImagePart(base64 = "iVBORw0KGgoAAAANSUhEUg==", wireMime = ImagePart.WireMime.Png),
),
),
),
turns = 1,
toolCalls = 0,
toolCallLimit = 0,
tokensUsed = null,
memory = emptyMap(),
requestId = "req-1", sessionId = null, manifestHash = null,
)

val encoded = SnapshotJson.encode(original)
val decoded = SnapshotJson.decode(encoded)

assertEquals(1, decoded.messages.size)
val msg = decoded.messages.single()
assertEquals("user", msg.role)
assertEquals("What's in this image?", msg.content)
val images = msg.images
assertNotNull(images, "images must rehydrate")
assertEquals(1, images.size)
assertEquals("iVBORw0KGgoAAAANSUhEUg==", images.single().base64)
assertEquals(ImagePart.WireMime.Png, images.single().wireMime)
}

@Test
fun `multiple images of mixed mimes round-trip in order`() {
val original = SessionSnapshot(
messages = listOf(
LlmMessage(
role = "user",
content = "Compare these.",
images = listOf(
ImagePart(base64 = "AAA1", wireMime = ImagePart.WireMime.Png),
ImagePart(base64 = "BBB2", wireMime = ImagePart.WireMime.Jpeg),
ImagePart(base64 = "CCC3", wireMime = ImagePart.WireMime.Webp),
ImagePart(base64 = "DDD4", wireMime = ImagePart.WireMime.Gif),
),
),
),
turns = 1,
toolCalls = 0,
toolCallLimit = 0,
tokensUsed = null,
memory = emptyMap(),
requestId = "req-2", sessionId = null, manifestHash = null,
)

val decoded = SnapshotJson.decode(SnapshotJson.encode(original))
val images = decoded.messages.single().images!!
assertEquals(listOf("AAA1", "BBB2", "CCC3", "DDD4"), images.map { it.base64 })
assertEquals(
listOf(
ImagePart.WireMime.Png,
ImagePart.WireMime.Jpeg,
ImagePart.WireMime.Webp,
ImagePart.WireMime.Gif,
),
images.map { it.wireMime },
)
}

@Test
fun `message with no images round-trips with null images field — back-compat`() {
val original = SessionSnapshot(
messages = listOf(LlmMessage(role = "user", content = "no vision")),
turns = 1,
toolCalls = 0,
toolCallLimit = 0,
tokensUsed = null,
memory = emptyMap(),
requestId = "req-3", sessionId = null, manifestHash = null,
)

val encoded = SnapshotJson.encode(original)
// Wire shape: when images is null, the `images` key must be omitted —
// this preserves byte-identity with pre-#2866 snapshots.
assertTrue(
!encoded.contains("\"images\""),
"no images → no `images` key in the JSON (back-compat): $encoded",
)

val decoded = SnapshotJson.decode(encoded)
assertNull(decoded.messages.single().images)
}

@Test
fun `pre-2866 snapshots without an images key decode with null images`() {
// Hand-crafted legacy snapshot shape — older clients that saved
// before #2866 should still load cleanly.
val legacy = """{
"messages":[{"role":"user","content":"hi"}],
"turns":0,
"toolCalls":0,
"toolCallLimit":0,
"memory":{},
"requestId":"legacy-1"
}""".trimIndent()

val decoded = SnapshotJson.decode(legacy)
assertEquals(1, decoded.messages.size)
assertNull(decoded.messages.single().images, "legacy snapshots decode with null images")
}

@Test
fun `unknown image mime values are skipped on decode — defensive`() {
// A future-extended or hand-edited snapshot with an unknown mime
// should NOT crash resume — the unknown part is dropped, the rest
// of the message rehydrates fine.
val custom = """{
"messages":[{
"role":"user",
"content":"mixed-mime",
"images":[
{"base64":"OK1","mime":"image/png"},
{"base64":"OK2","mime":"image/unknown-future-format"},
{"base64":"OK3","mime":"image/jpeg"}
]
}],
"turns":0,"toolCalls":0,"toolCallLimit":0,"memory":{},"requestId":"r"
}""".trimIndent()

val decoded = SnapshotJson.decode(custom)
val images = decoded.messages.single().images!!
assertEquals(2, images.size, "the unknown-mime entry should be skipped")
assertEquals(listOf("OK1", "OK3"), images.map { it.base64 })
}
}
Loading