diff --git a/README.md b/README.md index 30e62a7..ae28cf5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,31 @@ ConversationFactoryProvider | `IN_MEMORY` | `InMemoryConversation` | Testing, ephemeral chats | | `STORED` | `StoredConversation` | Production, persistent chats | +## Durable Assistant Assets + +`StoredConversation` persists `DurableAsset` metadata carried by an `AssistantMessage` and +restores those assets when the conversation is loaded again: + +```kotlin +val message = AssistantMessage( + content = "Here is the report", + assets = listOf(durableAsset), +) +conversation.addMessage(message) +``` + +Asset metadata is stored in `StoredAsset` nodes connected to the message through `HAS_ASSET`. +The content bytes are not stored in the graph. `storageUri` remains an opaque reference resolved +by the `AssetStore` that originally materialized the asset. + +Only `DurableAsset` instances are persisted. Ephemeral or application-specific `Asset` +implementations remain available while the message is pending in memory but cannot be restored +after a reload. Applications should materialize temporary assets before adding the assistant +message to a stored conversation. + +Deleting a session deletes its `StoredAsset` metadata nodes. It does not delete externally stored +content; retention and content deletion remain the responsibility of the corresponding `AssetStore`. + ## Message Events Subscribe to message lifecycle events for real-time updates: diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/adapter/StoredConversation.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/adapter/StoredConversation.kt index 09e0470..314f30b 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/adapter/StoredConversation.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/adapter/StoredConversation.kt @@ -16,8 +16,10 @@ package com.embabel.chat.store.adapter import com.embabel.agent.api.identity.User +import com.embabel.chat.AssistantMessage import com.embabel.chat.AssetTracker import com.embabel.chat.Conversation +import com.embabel.chat.DurableAsset import com.embabel.chat.Message import com.embabel.chat.MessageRole import com.embabel.chat.event.MessageEvent @@ -25,6 +27,7 @@ import com.embabel.chat.store.embedding.MessageEmbedder import com.embabel.chat.store.event.MessagePersistedEvent import com.embabel.chat.store.event.SessionEventAwaiter import com.embabel.chat.store.model.AttachmentData +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.MessageData import com.embabel.chat.store.model.StoredSession import com.embabel.chat.store.model.StoredUser @@ -101,7 +104,7 @@ class StoredConversation( private val logger = LoggerFactory.getLogger(StoredConversation::class.java) - private val pendingMessages = ConcurrentLinkedQueue() + private val pendingMessages = ConcurrentLinkedQueue() /** * Returns true since this conversation is backed by persistent storage. @@ -116,8 +119,8 @@ class StoredConversation( get() { val dbMessages = repository.getMessages(id) val dbMessageIds = dbMessages.mapTo(HashSet()) { it.messageId } - val pending = pendingMessages.filter { it.messageId !in dbMessageIds } - return dbMessages.map { it.toMessage() } + pending.map { it.toMessage() } + val pending = pendingMessages.filter { it.messageData.messageId !in dbMessageIds } + return dbMessages.map { it.toMessage() } + pending.map { it.message } } /** @@ -257,6 +260,17 @@ class StoredConversation( attachments: List = emptyList() ): String { val messageData = MessageData.from(message, messageId = UUIDv7.generateString()) + val messageAssets = (message as? AssistantMessage)?.assets.orEmpty() + val durableAssets = messageAssets.filterIsInstance().map { + AssetData.from(it, messageData.messageId) + } + if (messageAssets.size != durableAssets.size) { + logger.warn( + "Message {} has {} non-durable assets that will not survive conversation reload", + messageData.messageId, + messageAssets.size - durableAssets.size, + ) + } // Generate an interim title from the first user message so the session // appears immediately in the UI. The LLM will replace it later once @@ -281,17 +295,26 @@ class StoredConversation( ) // Add to pending buffer so getMessages() returns this message immediately - pendingMessages.add(messageData) + val pendingMessage = PendingMessage(messageData, message) + pendingMessages.add(pendingMessage) // DB write — asynchronous, non-blocking. The pending buffer ensures // consistent reads while the write is in flight. scope.launch { try { val messageDataForDb = embedMessageData(message, messageData) - val updatedSession = addMessageWithAwait(id, messageDataForDb, from, to, signal, attachments) + val updatedSession = addMessageWithAwait( + id, + messageDataForDb, + from, + to, + signal, + attachments, + durableAssets, + ) // Persisted — remove from pending buffer (DB is now the source of truth) - pendingMessages.remove(messageData) + pendingMessages.remove(pendingMessage) // PERSISTED event try { @@ -381,10 +404,11 @@ class StoredConversation( author: StoredUser?, recipient: StoredUser?, signal: CompletableDeferred, - attachments: List = emptyList() + attachments: List = emptyList(), + assets: List = emptyList(), ): StoredSession { return try { - repository.addMessage(sessionId, messageData, author, recipient, attachments) + persistMessage(sessionId, messageData, author, recipient, attachments, assets) } catch (e: IllegalArgumentException) { if (e.message?.contains("Session not found") != true) throw e @@ -393,10 +417,28 @@ class StoredConversation( sessionId, messageData.messageId ) sessionEventAwaiter.awaitSession(signal) - repository.addMessage(sessionId, messageData, author, recipient, attachments) + persistMessage(sessionId, messageData, author, recipient, attachments, assets) } } + private fun persistMessage( + sessionId: String, + messageData: MessageData, + author: StoredUser?, + recipient: StoredUser?, + attachments: List, + assets: List, + ): StoredSession = if (assets.isEmpty()) { + repository.addMessage(sessionId, messageData, author, recipient, attachments) + } else { + repository.addMessageWithAssets(sessionId, messageData, author, recipient, attachments, assets) + } + + private data class PendingMessage( + val messageData: MessageData, + val message: Message, + ) + companion object { /** * Truncate user message content into a short interim title. diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/AssetData.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/AssetData.kt new file mode 100644 index 0000000..04789ba --- /dev/null +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/AssetData.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.chat.store.model + +import com.embabel.chat.DurableAsset +import org.drivine.annotation.NodeFragment +import org.drivine.annotation.NodeId +import java.time.Instant + +/** + * Persisted metadata for a [DurableAsset]. Content remains in the external + * [storageUri] managed by the originating asset store. + */ +@NodeFragment(labels = ["StoredAsset"]) +data class AssetData( + @NodeId val storedAssetId: String, + val assetId: String, + val name: String, + val mimeType: String?, + val sizeBytes: Long, + val contentHash: String, + val storageUri: String, + val createdAt: Instant, +) { + + fun toAsset(): DurableAsset = DurableAsset( + id = assetId, + name = name, + mimeType = mimeType, + sizeBytes = sizeBytes, + contentHash = contentHash, + storageUri = storageUri, + timestamp = createdAt, + ) + + companion object { + + @JvmStatic + fun from(asset: DurableAsset, messageId: String): AssetData = AssetData( + storedAssetId = "$messageId:${asset.id}", + assetId = asset.id, + name = asset.name, + mimeType = asset.mimeType, + sizeBytes = asset.sizeBytes, + contentHash = asset.contentHash, + storageUri = asset.storageUri, + createdAt = asset.timestamp, + ) + } +} diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableMessage.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableMessage.kt index 551f00a..8de5f37 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableMessage.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableMessage.kt @@ -21,8 +21,8 @@ import org.drivine.annotation.GraphView import org.drivine.annotation.Root /** - * Delete-only view of a message: the `:StoredMessage` node plus the `:Attachment` nodes - * that hang off it, and nothing else. + * Delete-only view of a message: the `:StoredMessage` node plus its owned attachment + * and durable asset metadata nodes, and nothing else. * * Exists because the cascade boundary IS the view shape. [DeletableSession] previously * declared its children as bare [MessageData], which was correct while a message owned no @@ -37,6 +37,7 @@ import org.drivine.annotation.Root * Neo4j structure traversed: * ``` * (msg:StoredMessage)-[:HAS_ATTACHMENT]->(att:Attachment) + * (msg:StoredMessage)-[:HAS_ASSET]->(asset:StoredAsset) * ``` * * @see DeletableSession for the session-level cascade this composes into @@ -52,5 +53,12 @@ data class DeletableMessage( * The message's attachments, deleted with it. */ @GraphRelationship(type = "HAS_ATTACHMENT", direction = Direction.OUTGOING) - val attachments: List = emptyList() + val attachments: List = emptyList(), + + /** + * The message's durable asset metadata, deleted with it. + * External content retention remains the asset store's responsibility. + */ + @GraphRelationship(type = "HAS_ASSET", direction = Direction.OUTGOING) + val assets: List = emptyList(), ) diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableSession.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableSession.kt index ad3bab1..42e879f 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableSession.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/DeletableSession.kt @@ -29,21 +29,20 @@ import org.drivine.annotation.Root * * Deliberately narrow: * - The child is [DeletableMessage], which carries the `:StoredMessage` node and its - * HAS_ATTACHMENT children — but **not** [SimpleStoredMessage] (which carries + * HAS_ATTACHMENT and HAS_ASSET children — but **not** [SimpleStoredMessage] (which carries * AUTHORED_BY/SENT_TO → [StoredUser]). A DETACH DELETE of each message node drops * those edges while leaving the `:User` nodes intact. * - The OWNED_BY owner is omitted entirely. Because the view declares only * HAS_MESSAGE, the cascade physically cannot reach `:User` — the safety is * structural, not a flag. * - * Attachments are included precisely because that structural narrowness cuts both ways: - * a message's attachments are owned by the message and reachable from nowhere else, so a - * cascade that stops at `:StoredMessage` leaves `:Attachment` nodes orphaned, referencing - * stored bytes nothing can reach. + * Attachments and asset metadata are included precisely because that structural narrowness + * cuts both ways: they are owned by the message and reachable from nowhere else. * * Neo4j structure traversed: * ``` * (session:ChatSession)-[:HAS_MESSAGE]->(msg:StoredMessage)-[:HAS_ATTACHMENT]->(att:Attachment) + * \-[:HAS_ASSET]->(asset:StoredAsset) * ``` * * @see com.embabel.chat.store.model.StoredSession for the full read view diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/StoredMessage.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/StoredMessage.kt index 4ce3955..63493c5 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/StoredMessage.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/model/StoredMessage.kt @@ -16,6 +16,7 @@ package com.embabel.chat.store.model import com.embabel.chat.AssistantMessage +import com.embabel.chat.DurableAsset import com.embabel.chat.Message import com.embabel.chat.MessageRole import com.embabel.chat.SystemMessage @@ -110,9 +111,10 @@ data class MessageData( /** * Convert to a rich agent-api [Message] type. */ - fun toMessage(): Message = when (role) { + @JvmOverloads + fun toMessage(assets: List = emptyList()): Message = when (role) { MessageRole.USER -> UserMessage(content = content, timestamp = createdAt) - MessageRole.ASSISTANT -> AssistantMessage(content = content, timestamp = createdAt) + MessageRole.ASSISTANT -> AssistantMessage(content = content, assets = assets, timestamp = createdAt) MessageRole.SYSTEM -> SystemMessage(content = content, timestamp = createdAt) } } @@ -162,7 +164,11 @@ data class SimpleStoredMessage( * reload rather than existing only for the turn it arrived in. */ @GraphRelationship(type = "HAS_ATTACHMENT", direction = Direction.OUTGOING) - val attachments: List = emptyList() + val attachments: List = emptyList(), + + /** Durable assets produced with this assistant message. */ + @GraphRelationship(type = "HAS_ASSET", direction = Direction.OUTGOING) + val assets: List = emptyList(), ) : Message { // Convenience accessors for common properties val messageId: String get() = message.messageId @@ -177,7 +183,7 @@ data class SimpleStoredMessage( /** * Convert to a rich agent-api [Message] type. */ - fun toMessage(): Message = message.toMessage() + fun toMessage(): Message = message.toMessage(assets.map { it.toAsset() }) } /** @@ -200,7 +206,11 @@ data class AttributedMessage( /** Files attached to this message. See [SimpleStoredMessage.attachments]. */ @GraphRelationship(type = "HAS_ATTACHMENT", direction = Direction.OUTGOING) - val attachments: List = emptyList() + val attachments: List = emptyList(), + + /** Durable assets produced with this assistant message. */ + @GraphRelationship(type = "HAS_ASSET", direction = Direction.OUTGOING) + val assets: List = emptyList(), ) /** diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepository.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepository.kt index 6f69189..2f0ee20 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepository.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepository.kt @@ -16,6 +16,7 @@ package com.embabel.chat.store.repository import com.embabel.chat.store.model.AttachmentData +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.MessageData import com.embabel.chat.store.model.SessionSummary import com.embabel.chat.store.model.SimpleStoredMessage @@ -165,6 +166,21 @@ interface ChatSessionRepository { attachments: List = emptyList() ): StoredSession + /** + * Add a message and its durable asset metadata to a session. + * + * This is separate from [addMessage] to preserve the existing message and attachment + * write path for callers that do not produce assets. + */ + fun addMessageWithAssets( + sessionId: String, + messageData: MessageData, + author: StoredUser? = null, + recipient: StoredUser? = null, + attachments: List = emptyList(), + assets: List, + ): StoredSession + /** * Get all messages in a session. * diff --git a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImpl.kt b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImpl.kt index 29fe9f8..f6fef91 100644 --- a/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImpl.kt +++ b/embabel-chat-store/src/main/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImpl.kt @@ -17,6 +17,7 @@ package com.embabel.chat.store.repository import com.embabel.chat.store.event.SessionCreatedEvent import com.embabel.chat.store.model.AttachmentData +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.AttributedMessage import com.embabel.chat.store.model.DeletableSession import com.embabel.chat.store.model.MessageData @@ -270,10 +271,43 @@ open class ChatSessionRepositoryImpl( author: StoredUser?, recipient: StoredUser?, attachments: List + ): StoredSession = addMessageInternal( + sessionId = sessionId, + messageData = messageData, + author = author, + recipient = recipient, + attachments = attachments, + assets = emptyList(), + ) + + @Transactional + override fun addMessageWithAssets( + sessionId: String, + messageData: MessageData, + author: StoredUser?, + recipient: StoredUser?, + attachments: List, + assets: List, + ): StoredSession = addMessageInternal( + sessionId = sessionId, + messageData = messageData, + author = author, + recipient = recipient, + attachments = attachments, + assets = assets, + ) + + private fun addMessageInternal( + sessionId: String, + messageData: MessageData, + author: StoredUser?, + recipient: StoredUser?, + attachments: List, + assets: List, ): StoredSession { logger.debug( - "Adding message {} to session {} ({} attachments)", - messageData.messageId, sessionId, attachments.size, + "Adding message {} to session {} ({} attachments, {} assets)", + messageData.messageId, sessionId, attachments.size, assets.size, ) // Verify the session exists and advance its activity in a single statement — MERGE on @@ -290,7 +324,8 @@ open class ChatSessionRepositoryImpl( message = messageData, author = author?.let { UserRef(it.id) }, recipient = recipient?.let { UserRef(it.id) }, - attachments = attachments + attachments = attachments, + assets = assets, ) ) graphObjectManager.save(newMessage, CascadeType.PRESERVE) diff --git a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/MessageMapperTest.kt b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/MessageMapperTest.kt index 4b6169a..f5855da 100644 --- a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/MessageMapperTest.kt +++ b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/MessageMapperTest.kt @@ -16,9 +16,11 @@ package com.embabel.chat.store.adapter import com.embabel.chat.AssistantMessage +import com.embabel.chat.DurableAsset import com.embabel.chat.MessageRole import com.embabel.chat.SystemMessage import com.embabel.chat.UserMessage +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.MessageData import com.embabel.chat.store.model.SimpleStoredMessage import com.embabel.chat.store.model.StoredUser @@ -31,6 +33,52 @@ import java.time.Instant */ class MessageDataConversionTest { + @Test + fun `AssetData round trips DurableAsset metadata`() { + val timestamp = Instant.parse("2026-08-20T00:00:00Z") + val asset = DurableAsset( + id = "asset-1", + name = "report.pdf", + mimeType = "application/pdf", + sizeBytes = 42, + contentHash = "abc123", + storageUri = "asset://reports/asset-1", + timestamp = timestamp, + ) + + val restored = AssetData.from(asset, "msg-1").toAsset() + + assertEquals(asset, restored) + assertTrue(restored.persistent()) + } + + @Test + fun `SimpleStoredMessage restores durable assistant assets`() { + val asset = AssetData( + storedAssetId = "msg-1:asset-1", + assetId = "asset-1", + name = "report.pdf", + mimeType = "application/pdf", + sizeBytes = 42, + contentHash = "abc123", + storageUri = "asset://reports/asset-1", + createdAt = Instant.parse("2026-08-20T00:00:00Z"), + ) + val stored = SimpleStoredMessage( + message = MessageData( + messageId = "msg-1", + role = MessageRole.ASSISTANT, + content = "Here is the report", + createdAt = Instant.now(), + ), + assets = listOf(asset), + ) + + val restored = stored.toMessage() as AssistantMessage + + assertEquals(listOf(asset.toAsset()), restored.assets) + } + @Test fun `MessageData from UserMessage converts correctly`() { val timestamp = Instant.now() diff --git a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/StoredConversationTest.kt b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/StoredConversationTest.kt index 6c38b07..c70b850 100644 --- a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/StoredConversationTest.kt +++ b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/adapter/StoredConversationTest.kt @@ -18,6 +18,7 @@ package com.embabel.chat.store.adapter import com.embabel.chat.MessageRole import com.embabel.chat.UserMessage import com.embabel.chat.AssistantMessage +import com.embabel.chat.DurableAsset import com.embabel.chat.SystemMessage import com.embabel.chat.event.MessageEvent import com.embabel.chat.event.MessageStatus @@ -25,6 +26,7 @@ import com.embabel.chat.store.embedding.EmbeddingResult import com.embabel.chat.store.embedding.MessageEmbedder import com.embabel.chat.store.event.SessionEventAwaiter import com.embabel.chat.store.model.MessageData +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.SessionData import com.embabel.chat.store.model.SimpleStoredMessage import com.embabel.chat.store.model.StoredSession @@ -225,6 +227,48 @@ class StoredConversationTest { latch.countDown() } + @Test + fun `assistant durable assets are visible while pending and persisted with the message`() { + val persistenceLatch = CountDownLatch(1) + whenever(repository.addMessageWithAssets(eq(sessionId), any(), any(), any(), any(), any())).thenAnswer { + val messageData = it.getArgument(1) + val assets = it.getArgument>(5) + persistenceLatch.countDown() + StoredSession( + session = SessionData(sessionId = sessionId, title = "Test", createdAt = Instant.now()), + owner = user, + messages = listOf(SimpleStoredMessage(messageData, assets = assets)), + ) + } + whenever(repository.getMessages(sessionId)).thenReturn(emptyList()) + val asset = DurableAsset( + id = "asset-1", + name = "report.pdf", + mimeType = "application/pdf", + sizeBytes = 42, + contentHash = "abc123", + storageUri = "asset://reports/asset-1", + ) + val conversation = createConversation() + + conversation.addMessage(AssistantMessage("Here is the report", assets = listOf(asset))) + + val pending = conversation.messages.single() as AssistantMessage + assertEquals(listOf(asset), pending.assets) + assertTrue(persistenceLatch.await(5, TimeUnit.SECONDS)) + val assetsCaptor = argumentCaptor>() + verify(repository, timeout(5000)).addMessageWithAssets( + eq(sessionId), + any(), + any(), + any(), + any(), + assetsCaptor.capture(), + ) + assertEquals(asset.id, assetsCaptor.firstValue.single().assetId) + assertTrue(assetsCaptor.firstValue.single().storedAssetId.endsWith(":${asset.id}")) + } + // ==================== Event tests ==================== @Test diff --git a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImplTest.kt b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImplTest.kt index 1ba856e..241d22b 100644 --- a/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImplTest.kt +++ b/embabel-chat-store/src/test/kotlin/com/embabel/chat/store/repository/ChatSessionRepositoryImplTest.kt @@ -18,6 +18,7 @@ package com.embabel.chat.store.repository import com.embabel.chat.MessageRole import com.embabel.chat.store.TestApplication import com.embabel.chat.store.model.AttachmentData +import com.embabel.chat.store.model.AssetData import com.embabel.chat.store.model.MessageData import com.embabel.chat.store.model.TestSessionUser import com.embabel.chat.store.util.UUIDv7 @@ -887,6 +888,39 @@ class ChatSessionRepositoryImplTest { assertTrue(chatSessionRepository.getMessages(sessionId).single().attachments.isEmpty()) } + @Test + fun `durable assets survive a reload`() { + val sessionId = UUID.randomUUID().toString() + chatSessionRepository.createSession(sessionId, testUser, "With Asset") + val messageId = UUID.randomUUID().toString() + val asset = AssetData( + storedAssetId = "$messageId:asset-1", + assetId = "asset-${UUID.randomUUID()}", + name = "report.pdf", + mimeType = "application/pdf", + sizeBytes = 4096, + contentHash = "c".repeat(64), + storageUri = "asset://reports/report.pdf", + createdAt = Instant.now(), + ) + + chatSessionRepository.addMessageWithAssets( + sessionId = sessionId, + messageData = MessageData( + messageId = messageId, + role = MessageRole.ASSISTANT, + content = "Here is the report", + createdAt = Instant.now(), + ), + assets = listOf(asset), + ) + + val reloaded = chatSessionRepository.getMessages(sessionId).single() + assertEquals(listOf(asset), reloaded.assets) + assertEquals(listOf(asset.toAsset()), (reloaded.toMessage() as com.embabel.chat.AssistantMessage).assets) + assertEquals(1, countNodes("StoredAsset", "storedAssetId", asset.storedAssetId)) + } + @Test fun `deleting a session removes its attachments`() { // Attachments hang off messages, and messages cascade on session delete. If the @@ -919,4 +953,36 @@ class ChatSessionRepositoryImplTest { assertEquals(0, countNodes("Attachment", "attachmentId", attachment.attachmentId)) } + + @Test + fun `deleting a session removes its durable asset metadata`() { + val sessionId = UUID.randomUUID().toString() + chatSessionRepository.createSession(sessionId, testUser, "Doomed Asset") + val messageId = UUID.randomUUID().toString() + val asset = AssetData( + storedAssetId = "$messageId:asset-1", + assetId = "asset-${UUID.randomUUID()}", + name = "report.pdf", + mimeType = "application/pdf", + sizeBytes = 4096, + contentHash = "d".repeat(64), + storageUri = "asset://reports/report.pdf", + createdAt = Instant.now(), + ) + chatSessionRepository.addMessageWithAssets( + sessionId = sessionId, + messageData = MessageData( + messageId = messageId, + role = MessageRole.ASSISTANT, + content = "Here is the report", + createdAt = Instant.now(), + ), + assets = listOf(asset), + ) + assertEquals(1, countNodes("StoredAsset", "storedAssetId", asset.storedAssetId)) + + chatSessionRepository.deleteSession(sessionId) + + assertEquals(0, countNodes("StoredAsset", "storedAssetId", asset.storedAssetId)) + } } diff --git a/pom.xml b/pom.xml index 6ddb12d..bf2356b 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ 0.0.79 2.0.0-SNAPSHOT - 1.5.0-SNAPSHOT + 1.5.1-SNAPSHOT 2.3.21