Skip to content

Web host: GET /agent/persistence/{sessionId}/message/{sequence}/content/{contentIndex}/image endpoint #1218

Description

@JoshuaRowePhantom

Part of #1216

Summary

Add a new HTTP endpoint that streams a single persisted image DataContent's bytes for use as an <img src="..."> target in the agent-chat HTML view. The endpoint is registered inside AgentPersistenceEndpointRouteBuilderExtensions.MapAgentPersistenceEndpoints, so BOTH WorkspacesWebHost (in-app) and Phantom.Workspaces.Web.Server (external host) expose it automatically — same seam introduced by #1209.

GET /agent/persistence/{sessionId}/message/{sequence:long}/content/{contentIndex:int}/image

Dependencies

Root Cause / Current Behavior

Persisted image bytes live inside ChatMessage.Payload on MongoDbPersistedMessageDocument. The HTML view (sub-item C) needs a URL that returns those bytes with a proper Content-Type so <img> can render them without inlining a multi-megabyte base64 data URI into the HTML fragment stream.

Web host & routing today. features/Phantom.Workspaces/Services/WorkspacesWebHost.cs:84-90 and features/Phantom.Workspaces.Web.Server/Program.cs:53-59 both compose the same route surface via extension methods:

  • MapWebDataAccessEndpoints
  • MapAgentEndpoints
  • MapTransportReverseEndpoints
  • MapWorkspaceToolEndpoints
  • MapAgentPersistenceEndpoints

The last currently exposes POST /agent/persistence/store, .../restore, .../messages, .../sub-agent-links/add, .../sub-agent-links/read — see features/Phantom.Workspaces.Web.Server/AgentPersistenceEndpointRouteBuilderExtensions.cs:106-132. Adding the new endpoint inside the same MapAgentPersistenceEndpoints method means both hosts pick it up with no wiring changes — see #1209.

Retrieval source. Reads MUST go through IAgentPersistenceStore (Mongo), NOT AgentChatSessionCache. AgentChatSessionCache (Phantom.Workspaces.Llm.Core/AgentChatSessionCache.cs:20-35, registered as a singleton at Phantom.Workspaces.Web.Server/Program.cs:38) only holds live AgentChat objects for currently-open sessions; a reload, restart, or opening a historical session from a different host process would break the image tags. The persistence store is the durable source of truth and is already reachable from web endpoints — see AgentPersistenceEndpointRouteBuilderExtensions.cs:110-113 (RequestServices.GetService<IAgentPersistenceStore>()).

Affected Files

File Contribution
features/Phantom.Workspaces.Web.Server/AgentPersistenceEndpointRouteBuilderExtensions.cs Register GET /agent/persistence/{sessionId}/message/{sequence:long}/content/{contentIndex:int}/image inside MapAgentPersistenceEndpoints; call IAgentPersistenceStore.ReadMessageContentAsync from #1217; map result → 200 with bytes / 404 / 400 / 503.
features/Phantom.Workspaces/Services/WorkspacesWebHost.cs No new call needed — the extension is already chained around :84-90; endpoint appears automatically. Cited for coverage.
features/Phantom.Workspaces.Web.Server/Program.cs No new call needed — endpoint appears via existing app.MapAgentPersistenceEndpoints() (:59). Cited for coverage.

Design / Fix

Endpoint contract

GET /agent/persistence/{sessionId}/message/{sequence:long}/content/{contentIndex:int}/image

  • Success (200): raw image bytes as body; Content-Type set from DataContent.MediaType (verbatim); Cache-Control: private, max-age=3600; Content-Length from bytes.
  • 400 if sessionId is missing or whitespace after route decoding.
  • 404 if the message at that sequence does not exist for sessionId, if contentIndex is out of range, or if the content at that index is not a DataContent with an image/* media type.
  • 503 if IAgentPersistenceStore is not registered in the request-service container.
  • Optional query parameter ?v={sha256(Data)[..16]} for cache-busting; endpoint MAY validate the fingerprint and 404 on mismatch (see Persistence: read-by-index for image DataContent by (AgentSessionId, Sequence, ContentIndex) #1217 identity scheme). MVP: accept and ignore.

Sketch

endpointRouteBuilder.MapGet(
    "/agent/persistence/{sessionId}/message/{sequence:long}/content/{contentIndex:int}/image",
    async (HttpContext http, string sessionId, long sequence, int contentIndex) =>
{
    if (string.IsNullOrWhiteSpace(sessionId)) return Results.BadRequest();

    var store = http.RequestServices.GetService<IAgentPersistenceStore>();
    if (store is null) return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);

    var result = await store.ReadMessageContentAsync(
        new ReadMessageContentRequest
        {
            AgentSessionId = sessionId,
            Sequence       = sequence,
            ContentIndex   = contentIndex,
        },
        http.RequestAborted).ConfigureAwait(false);

    if (!result.Found
        || string.IsNullOrEmpty(result.MediaType)
        || !result.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
    {
        return Results.NotFound();
    }

    http.Response.Headers.CacheControl = "private, max-age=3600";
    return Results.File(
        fileContents: result.Data.ToArray(),
        contentType: result.MediaType,
        fileDownloadName: null,
        enableRangeProcessing: false);
});

Notes:

  • Registration inside MapAgentPersistenceEndpoints means both WorkspacesWebHost and Phantom.Workspaces.Web.Server expose the route automatically.
  • The endpoint is deliberately thin — all durable state access lives in ReadMessageContentAsync (from Persistence: read-by-index for image DataContent by (AgentSessionId, Sequence, ContentIndex) #1217).
  • Non-image DataContent (e.g. application/pdf) intentionally 404s at this endpoint: this URL is image-only. A future non-image endpoint can be added separately.

Expected Tests

Naming style Subject_Scenario_ExpectedOutcome; class name matches the existing endpoint suite (AgentPersistenceEndpointTests in features/Phantom.Workspaces.Web.Server.Tests).

Test Name Class What It Verifies
AgentImageEndpoint_WhenRequestedByMessageAndIndex_ReturnsImageBytesWithMediaType AgentPersistenceEndpointTests After storing a message with DataContent(image/png, bytes), GET /agent/persistence/{sid}/message/{seq}/content/{idx}/image returns 200, body bytes equal the persisted Data, Content-Type equals image/png.
AgentImageEndpoint_WhenSequenceOutOfRange_Returns404 AgentPersistenceEndpointTests Nonexistent sequence for the session → 404.
AgentImageEndpoint_WhenContentIndexOutOfRange_Returns404 AgentPersistenceEndpointTests Valid message but no content at index → 404.
AgentImageEndpoint_WhenContentIsNotImage_Returns404 AgentPersistenceEndpointTests DataContent with non-image/* media type or TextContent at index → 404.
AgentImageEndpoint_WhenPersistenceStoreUnavailable_Returns503 AgentPersistenceEndpointTests When IAgentPersistenceStore is not registered → 503 (companion to existing StoreAsync_WhenServerReturns503_ThrowsHttpRequestException shape).
AgentImageEndpoint_WhenSessionIdMissing_Returns400 AgentPersistenceEndpointTests Empty/whitespace sessionId route param → 400.
AgentImageEndpoint_SetsCacheControlPrivateMaxAge AgentPersistenceEndpointTests Response includes Cache-Control: private, max-age=3600.
AgentImageEndpoint_IsRegisteredOnWorkspacesWebHost AgentPersistenceEndpointTests Coverage that MapAgentPersistenceEndpoints (used by both WorkspacesWebHost and Phantom.Workspaces.Web.Server) exposes the new route — same technique as existing endpoint-registration assertions in this suite (per #1209 seam).

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