Part of #1216
Summary
Add the ability to retrieve a single image DataContent's bytes + media type from the persistence layer, addressed by the composite content-identity tuple (AgentSessionId, Sequence, ContentIndex). This is the persistence-layer foundation for the hosted image endpoint (sub-item B) and the HTML <img> rendering (sub-item C) that together let the agent-chat HTML view show previews of persisted images (both user-pasted and assistant-generated) instead of a plain image/png text label.
Concretely: introduce a read-by-index method on IAgentPersistenceStore / MongoDbAgentPersistenceStore that returns the bytes and media type of the Nth AIContent on a persisted ChatMessage, plus define the identity scheme (with an optional ?v=sha256(Data)[..16] cache-busting fingerprint) that the rest of the split will use.
Dependencies
None.
Root Cause / Current Behavior
Images pasted into the queue composer (features/Phantom.Workspaces.Agent.Gui/Views/QueueComposerControl.axaml.cs:314-318) and images returned by chat providers are wrapped as new DataContent(imageData, mediaType) (see features/Phantom.Workspaces.Agent.Gui/ViewModels/DocumentModels/QueueComposerViewModel.cs:164) and attached to ChatMessage.Contents. Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.cs:~99 (inside StoreAsync) serializes the entire ChatMessage via JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions) and stores it as a BsonDocument on MongoDbPersistedMessageDocument:
var documents = newMessages.Select((message, index) => new MongoDbPersistedMessageDocument
{
AgentSessionId = request.Agent.AgentSessionId,
Sequence = nextSequence + index,
Payload = BsonDocument.Parse(JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions)),
}).ToArray();
ReadMessagesAsync (MongoDbAgentPersistenceStore.cs:~165-182) already selects by AgentSessionId, sorts by Sequence, and deserializes to ChatMessage[]. But there is no way to ask "give me the Nth image's bytes on the message at Sequence S in session X" — which is exactly what the HTTP endpoint in sub-item B needs.
Identity — DataContent has no stable id. Microsoft.Extensions.AI.DataContent exposes only Data and MediaType; there is no Id, no Name, no annotation, and ChatMessage.MessageId is never set or persisted anywhere in the codebase (grep confirms zero assignments). Confirmed via ChatHistoryItemViewModel.cs:140-141, CopilotSdkChatClient.cs:1043-1050, and ChatOutputHtmlRenderer.cs:523 (a hash-like tuple "data:{MediaType}\^A{Data.Length}" used only for streaming dedup, not identity).
Chosen composite key: AgentSessionId + Sequence + ContentIndex, where Sequence is MongoDbPersistedMessageDocument.Sequence (long) and ContentIndex is the position of the DataContent inside ChatMessage.Contents. This is stable under reloads because Mongo owns Sequence and content order within a serialized message is fixed. Optional hardening: append sha256(Data)[..16] as a ?v= query string parameter so the browser cache invalidates if bytes at the same index ever change; the server MAY validate the fingerprint and 404 on mismatch.
Affected Files
| File |
Contribution |
features/Phantom.Workspaces.Llm.Core/IAgentPersistenceStore.cs |
Add a read-by-index method returning bytes + media type for a single DataContent addressed by (AgentSessionId, Sequence, ContentIndex). |
features/Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.cs |
Implement the new method: BSON filter on AgentSessionId + Sequence, deserialize the single MongoDbPersistedMessageDocument.Payload to ChatMessage, index into Contents, return the DataContent's bytes + media type. |
features/Phantom.Workspaces.Data.MongoDB/MongoDbPersistedMessageDocument.cs |
No schema change; Sequence is already the anchor. Cited for context. |
Design / Fix
Content-identity scheme
(AgentSessionId: string, Sequence: long, ContentIndex: int). Stable across reload and across host processes because it is anchored on the durable Mongo document. Optional integrity/cache-busting: a short sha256(Data)[..16] fingerprint that callers (sub-item B's endpoint) can accept as a query parameter and optionally validate.
New read-by-index API (sketch)
Add to IAgentPersistenceStore:
public sealed record ReadMessageContentRequest
{
public required string AgentSessionId { get; init; }
public required long Sequence { get; init; }
public required int ContentIndex { get; init; }
}
public sealed record ReadMessageContentResult
{
/// <summary>The raw bytes of the addressed <see cref="DataContent"/>, or empty if not found / not a DataContent.</summary>
public required ReadOnlyMemory<byte> Data { get; init; }
/// <summary>The media type from <see cref="DataContent.MediaType"/> (e.g. "image/png"), or null if not found.</summary>
public string? MediaType { get; init; }
/// <summary>True when the addressed content was found and was a <see cref="DataContent"/>.</summary>
public bool Found { get; init; }
}
Task<ReadMessageContentResult> ReadMessageContentAsync(
ReadMessageContentRequest request,
CancellationToken cancellationToken);
Implementation in MongoDbAgentPersistenceStore (sketch):
public async Task<ReadMessageContentResult> ReadMessageContentAsync(
ReadMessageContentRequest request, CancellationToken cancellationToken)
{
var filter = Builders<MongoDbPersistedMessageDocument>.Filter.And(
Builders<MongoDbPersistedMessageDocument>.Filter.Eq(d => d.AgentSessionId, request.AgentSessionId),
Builders<MongoDbPersistedMessageDocument>.Filter.Eq(d => d.Sequence, request.Sequence));
var doc = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (doc is null) return new() { Data = default, MediaType = null, Found = false };
var message = JsonSerializer.Deserialize<ChatMessage>(
doc.Payload.ToJson(), AIJsonUtilities.DefaultOptions)!;
if (request.ContentIndex < 0 || request.ContentIndex >= message.Contents.Count)
return new() { Data = default, MediaType = null, Found = false };
if (message.Contents[request.ContentIndex] is not DataContent data)
return new() { Data = default, MediaType = null, Found = false };
return new() { Data = data.Data, MediaType = data.MediaType, Found = true };
}
Notes:
- Single-message BSON filter avoids deserializing an entire session for a single
<img> request.
- Media type is returned verbatim from
DataContent.MediaType. The endpoint (sub-item B) is responsible for the image/ prefix check and 404 mapping.
- No changes to
MongoDbPersistedMessageDocument schema; Sequence is already the anchor.
Expected Tests
Class names mirror existing suites in Phantom.Workspaces.Data.MongoDB.Tests (MongoDbAgentPersistenceStoreSlowTests, AgentPersistenceStoreContractTests). Naming style is Subject_Scenario_ExpectedOutcome.
| Test Name |
Class |
What It Verifies |
ReadMessageContentAsync_WithImageDataContent_ReturnsBytesAndMediaType |
MongoDbAgentPersistenceStoreSlowTests |
After StoreAsync of a ChatMessage containing a DataContent(image/png), ReadMessageContentAsync at that sequence + content index returns Found = true, Data equals the persisted bytes, MediaType == "image/png". |
ReadMessageContentAsync_WhenSequenceMissing_ReturnsNotFound |
MongoDbAgentPersistenceStoreSlowTests |
Non-existent Sequence for the session → Found = false, empty Data, null MediaType. |
ReadMessageContentAsync_WhenContentIndexOutOfRange_ReturnsNotFound |
MongoDbAgentPersistenceStoreSlowTests |
Valid message but ContentIndex beyond Contents.Count → Found = false. |
ReadMessageContentAsync_WhenContentIsNotDataContent_ReturnsNotFound |
MongoDbAgentPersistenceStoreSlowTests |
TextContent (or other non-DataContent) at the index → Found = false. |
ReadMessageContentAsync_WhenSessionIdDoesNotMatch_ReturnsNotFound |
MongoDbAgentPersistenceStoreSlowTests |
Message with that Sequence exists under a different session → Found = false (partition isolation). |
ReadMessageContentAsync_WithAssistantRoleImage_ReturnsBytes |
MongoDbAgentPersistenceStoreSlowTests |
Persisting a ChatRole.Assistant message with DataContent(image/png) and reading by index returns the bytes — confirms the identity scheme is role-agnostic (needed by sub-item C's assistant-image coverage). |
Part of #1216
Summary
Add the ability to retrieve a single image
DataContent's bytes + media type from the persistence layer, addressed by the composite content-identity tuple(AgentSessionId, Sequence, ContentIndex). This is the persistence-layer foundation for the hosted image endpoint (sub-item B) and the HTML<img>rendering (sub-item C) that together let the agent-chat HTML view show previews of persisted images (both user-pasted and assistant-generated) instead of a plainimage/pngtext label.Concretely: introduce a read-by-index method on
IAgentPersistenceStore/MongoDbAgentPersistenceStorethat returns the bytes and media type of the NthAIContenton a persistedChatMessage, plus define the identity scheme (with an optional?v=sha256(Data)[..16]cache-busting fingerprint) that the rest of the split will use.Dependencies
None.
Root Cause / Current Behavior
Images pasted into the queue composer (
features/Phantom.Workspaces.Agent.Gui/Views/QueueComposerControl.axaml.cs:314-318) and images returned by chat providers are wrapped asnew DataContent(imageData, mediaType)(seefeatures/Phantom.Workspaces.Agent.Gui/ViewModels/DocumentModels/QueueComposerViewModel.cs:164) and attached toChatMessage.Contents.Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.cs:~99(insideStoreAsync) serializes the entireChatMessageviaJsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions)and stores it as aBsonDocumentonMongoDbPersistedMessageDocument:ReadMessagesAsync(MongoDbAgentPersistenceStore.cs:~165-182) already selects byAgentSessionId, sorts bySequence, and deserializes toChatMessage[]. But there is no way to ask "give me the Nth image's bytes on the message at Sequence S in session X" — which is exactly what the HTTP endpoint in sub-item B needs.Identity —
DataContenthas no stable id.Microsoft.Extensions.AI.DataContentexposes onlyDataandMediaType; there is noId, noName, no annotation, andChatMessage.MessageIdis never set or persisted anywhere in the codebase (grep confirms zero assignments). Confirmed viaChatHistoryItemViewModel.cs:140-141,CopilotSdkChatClient.cs:1043-1050, andChatOutputHtmlRenderer.cs:523(a hash-like tuple"data:{MediaType}\^A{Data.Length}"used only for streaming dedup, not identity).Chosen composite key:
AgentSessionId + Sequence + ContentIndex, whereSequenceisMongoDbPersistedMessageDocument.Sequence(long) andContentIndexis the position of theDataContentinsideChatMessage.Contents. This is stable under reloads because Mongo ownsSequenceand content order within a serialized message is fixed. Optional hardening: appendsha256(Data)[..16]as a?v=query string parameter so the browser cache invalidates if bytes at the same index ever change; the server MAY validate the fingerprint and 404 on mismatch.Affected Files
features/Phantom.Workspaces.Llm.Core/IAgentPersistenceStore.csDataContentaddressed by(AgentSessionId, Sequence, ContentIndex).features/Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.csAgentSessionId + Sequence, deserialize the singleMongoDbPersistedMessageDocument.PayloadtoChatMessage, index intoContents, return theDataContent's bytes + media type.features/Phantom.Workspaces.Data.MongoDB/MongoDbPersistedMessageDocument.csSequenceis already the anchor. Cited for context.Design / Fix
Content-identity scheme
(AgentSessionId: string, Sequence: long, ContentIndex: int). Stable across reload and across host processes because it is anchored on the durable Mongo document. Optional integrity/cache-busting: a shortsha256(Data)[..16]fingerprint that callers (sub-item B's endpoint) can accept as a query parameter and optionally validate.New read-by-index API (sketch)
Add to
IAgentPersistenceStore:Implementation in
MongoDbAgentPersistenceStore(sketch):Notes:
<img>request.DataContent.MediaType. The endpoint (sub-item B) is responsible for theimage/prefix check and 404 mapping.MongoDbPersistedMessageDocumentschema;Sequenceis already the anchor.Expected Tests
Class names mirror existing suites in
Phantom.Workspaces.Data.MongoDB.Tests(MongoDbAgentPersistenceStoreSlowTests,AgentPersistenceStoreContractTests). Naming style isSubject_Scenario_ExpectedOutcome.ReadMessageContentAsync_WithImageDataContent_ReturnsBytesAndMediaTypeMongoDbAgentPersistenceStoreSlowTestsStoreAsyncof aChatMessagecontaining aDataContent(image/png),ReadMessageContentAsyncat that sequence + content index returnsFound = true,Dataequals the persisted bytes,MediaType == "image/png".ReadMessageContentAsync_WhenSequenceMissing_ReturnsNotFoundMongoDbAgentPersistenceStoreSlowTestsSequencefor the session →Found = false, emptyData, nullMediaType.ReadMessageContentAsync_WhenContentIndexOutOfRange_ReturnsNotFoundMongoDbAgentPersistenceStoreSlowTestsContentIndexbeyondContents.Count→Found = false.ReadMessageContentAsync_WhenContentIsNotDataContent_ReturnsNotFoundMongoDbAgentPersistenceStoreSlowTestsTextContent(or other non-DataContent) at the index →Found = false.ReadMessageContentAsync_WhenSessionIdDoesNotMatch_ReturnsNotFoundMongoDbAgentPersistenceStoreSlowTestsSequenceexists under a different session →Found = false(partition isolation).ReadMessageContentAsync_WithAssistantRoleImage_ReturnsBytesMongoDbAgentPersistenceStoreSlowTestsChatRole.Assistantmessage withDataContent(image/png)and reading by index returns the bytes — confirms the identity scheme is role-agnostic (needed by sub-item C's assistant-image coverage).