Skip to content

Persistence: read-by-index for image DataContent by (AgentSessionId, Sequence, ContentIndex) #1217

Description

@JoshuaRowePhantom

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.CountFound = 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    diagnosedRoot cause identifiedenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions