Skip to content

feat(aspnetcore): add AG-UI endpoint with approvals, inbound files, and turn history - #41

Merged
MatthewHsu1 merged 10 commits into
mainfrom
fm/agui-phase3
Oct 13, 2026
Merged

MatthewHsu1 merged 10 commits into
mainfrom
fm/agui-phase3

Conversation

@MatthewHsu1

@MatthewHsu1 MatthewHsu1 commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Intent

Check and finish AG-UI phase 3 in AgentCore. Phases 1 and 2 of the AG-UI endpoint are committed on local branches feat/agui-endpoint-phase1 (88b9375) and feat/agui-endpoint-phase2 (e11eea9), not pushed. Phase 3 is in progress and uncommitted in the main AgentCore folder. Its plan is docs/superpowers/plans/2026-10-13-ag-ui-endpoint-phase3.md: inbound caller files go to the blob store (never inline in rows), the browser holds stable absolute references minted per response, and trimmed client bodies keep working. Work in an own copy: carry over the uncommitted work, build, run the tests, and finish the open tasks, leaving the main AgentCore folder as it is.

What Changed

  • Adds an AG-UI endpoint (MapAgUi, default POST /agui/{entry}) built on the AGUI.* 1.0.0 packages. It streams one conversation turn as AG-UI events, maps message content, resumes pending tool approvals, and refuses runs through a new BeforeConversationAsync hook gate (ConversationGate, GatePoint.BeforeConversation, EntryTransport.AgUi). MapAgentCoreHost now maps it too, through a new agUiPattern parameter and an AgUi property on AgentCoreEndpoints.
  • Sends caller-attached files to the blob store instead of keeping them inline in stored rows (InboundFiles.FileAsync/RehydrateAsync, with an upload: file id prefix). The model gets the bytes back each time it is called. Each response mints stable absolute file links and a file download route, and BlobRef gets an optional DownloadName. Trimmed client bodies that leave out file bytes get their files restored from the stored user row.
  • Adds thread routes for a paged turns history and file access, mapped once per route builder. Also adds PendingApprovals.Read and ConversationTurnOrigin.KeepsWithdrawnTools, plus new tests for every AG-UI piece and an updated README.

Risk Assessment

⚠️ Medium: The thread routes (file door, link route, turns route) resolve the entry through the BeforeEntry hook, else the single declared entry, then run the normal entry and ownership gates. A host that declares 2 or more entries and has no BeforeEntry hook gets 404 on those routes (documented limit). With the hook they work from the first turn on. The F4 paging (cursor math with a one-turn margin) and the R1 approval pair across a page edge are in place and traced correct.

Testing

I built the change and ran the targeted AG-UI test classes: 104 ASP.NET and 8 Application tests, all green. Then I started a throwaway host app over the real AgentCore packages. I drove it with curl like a browser client. All 7 scenarios passed: upload, reload with absolute references, owner-only file door, link and turns routes, trimmed body with a reference, guards (cap 4, text/csv refused, foreign URL refused, references count toward the cap), paging (nextCursor chain, null at the end, 400 on a bad cursor, limit clamp), and a second MapAgUi call. The host has no UI, so there are no screenshots. The evidence is the curl transcript. I stopped the host and deleted its build output. The worktree is clean.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Caller uploads an inline PNG on /agui/main; the run streams a reply and the model gets the image bytes ✅ pass live drive-transcript.txt S1: RUN_STARTED..RUN_FINISHED success; model input 'user: text(look) | data(image/png,70 bytes)'
Reloading history (GET /agui/threads/{id}/turns) shows the file as a stable absolute reference, not inline bytes ✅ pass live drive-transcript.txt S2: attachment image = http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png
Owner opens the file door (302 to the blob link) and the link route (200 JSON url+expiresAt). Another user gets 404 on the door, link and turns (2-entry host with BeforeEntry hook) ✅ pass live drive-transcript.txt S3
Trimmed client body: a second run sends only the new message and re-uses the file by its reference; the model gets full history plus the image bytes ✅ pass live drive-transcript.txt S4: model input has turn 1 + 'user: text(again) | data(image/png,70 bytes)'
Adversarial guards: 5 images, a text/csv file, 5 references to a stored file, a foreign URL, and a non-owner run are all refused before the model ✅ pass live drive-transcript.txt S5: attachment_too_many (both cases), attachment_type_not_allowed, attachment_url_not_allowed, thread_not_found
Turns paging: limit=2 pages chain through nextCursor to a last page with no cursor; before=abc gives 400 with the send-back-nextCursor message; limit=999 is clamped and returns all 5 turns ✅ pass live drive-transcript.txt S6
Host calls MapAgUi twice (default plus /custom/{entry}); thread routes still answer 200 (no ambiguous-route 500) and the second pattern runs turns ✅ pass live drive-transcript.txt S7: turns 200, /custom/main 200
Evidence: Live curl transcript against the running host
=== S1: alice uploads an inline image on /agui/main (first run) ===
data: {"type":"RUN_STARTED","threadId":"thread-live-1","runId":"r1"}

data: {"type":"STATE_SNAPSHOT","snapshot":{"stage":"","phase":"before"}}

data: {"type":"TEXT_MESSAGE_START","messageId":"turn-0.0","role":"assistant","name":"greeter"}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"turn-0.0","delta":"seen it"}

data: {"type":"CUSTOM","name":"agentcore_message_committed","value":{"user_message_id":"u-r1","reply_message_id":"01a136a59e047ef58736c95c2f643eff"}}

data: {"type":"TEXT_MESSAGE_END","messageId":"turn-0.0"}

data: {"type":"STATE_SNAPSHOT","snapshot":{"stage":"","phase":"after","isTerminal":false}}

data: {"type":"RUN_FINISHED","threadId":"thread-live-1","runId":"r1","outcome":{"type":"success"}}


--- what the model received:
user: text(look) | data(image/png,70 bytes)
system: text(Today is Tuesday, 2026-10-13. The local time is 13:03, America/Denver (UTC-06:00).)

=== S2: reload history (GET turns) as alice ===
{"messages":[{"id":"u-r1","role":"user","content":[{"type":"text","text":"look"}],"attachments":[{"id":"stored-0","type":"image","name":"image","contentType":"image/png","status":{"type":"complete"},"content":[{"type":"image","image":"http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png","filename":null}]}]},{"id":"turn-0","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r1","reply_message_id":"01a136a59e047ef58736c95c2f643eff"}}],"status":{"type":"complete","reason":"unknown"}}],"nextCursor":null}
reference = http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png

=== S3: open the file door + link as alice and as bob ===
alice door: 302 -> https://blobs.test/thread-live-1/in-4eaddd5fddf64364.png?ttl=300
alice link: {"url":"https://blobs.test/thread-live-1/in-4eaddd5fddf64364.png?ttl=300","expiresAt":1791918487144} [200]
bob door:   404
bob link:   404
bob turns:  404

=== S4: trimmed client body: second run sends ONLY the new message, re-using the file by its reference ===
data: {"type":"RUN_STARTED","threadId":"thread-live-1","runId":"r2"}

data: {"type":"STATE_SNAPSHOT","snapshot":{"stage":"","phase":"before"}}

data: {"type":"TEXT_MESSAGE_START","messageId":"turn-1.0","role":"assistant","name":"greeter"}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"turn-1.0","delta":"seen it"}

data: {"type":"CUSTOM","name":"agentcore_message_committed","value":{"user_message_id":"u-r2","reply_message_id":"01a136a5a2cf74c19046e1cb3f5e09a3"}}

data: {"type":"TEXT_MESSAGE_END","messageId":"turn-1.0"}

data: {"type":"STATE_SNAPSHOT","snapshot":{"stage":"","phase":"after","isTerminal":false}}

data: {"type":"RUN_FINISHED","threadId":"thread-live-1","runId":"r2","outcome":{"type":"success"}}


--- what the model received:
user: text(look) | data(image/png,70 bytes)
assistant: text(seen it)
user: text(again) | data(image/png,70 bytes)
system: text(Today is Tuesday, 2026-10-13. The local time is 13:03, America/Denver (UTC-06:00).)

=== S5: guards ===
-- 5 images (cap is 4):
data: {"type":"RUN_ERROR","message":"a message carries at most 4 attachments; this one carries 5.","code":"attachment_too_many"}


-- text/csv file:
data: {"type":"RUN_ERROR","message":"\u0027text/csv\u0027 is not an accepted attachment type.","code":"attachment_type_not_allowed"}


-- 5 references to the stored file (cap is 4):
data: {"type":"RUN_ERROR","message":"a message carries at most 4 attachments; this one carries 5.","code":"attachment_too_many"}


-- a foreign url source:
data: {"type":"RUN_ERROR","message":"an attachment must carry its bytes; \u0027https://evil.example/x.png\u0027 is a link.","code":"attachment_url_not_allowed"}


-- bob runs a turn on alice's thread:
data: {"type":"RUN_ERROR","message":"no thread \u0027thread-live-1\u0027 for this caller. Create the thread first.","code":"thread_not_found"}

 [200]

=== S6: paging the turns route ===
page1: {"messages":[{"id":"u-r5","role":"user","content":[{"type":"text","text":"msg 5"}],"attachments":[]},{"id":"turn-3","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r5","reply_message_id":"01a136a5a96174b8813bffbb9e02321e"}}],"status":{"type":"complete","reason":"unknown"}},{"id":"u-r6","role":"user","content":[{"type":"text","text":"msg 6"}],"attachments":[]},{"id":"turn-4","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r6","reply_message_id":"01a136a5ab697779ac8e778e5d8a5e68"}}],"status":{"type":"complete","reason":"unknown"}}],"nextCursor":"3"}
nextCursor=3
page2: {"messages":[{"id":"u-r2","role":"user","content":[{"type":"text","text":"again"}],"attachments":[{"id":"stored-0","type":"image","name":"image","contentType":"image/png","status":{"type":"complete"},"content":[{"type":"image","image":"http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png","filename":null}]}]},{"id":"turn-1","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r2","reply_message_id":"01a136a5a2cf74c19046e1cb3f5e09a3"}}],"status":{"type":"complete","reason":"unknown"}},{"id":"u-r4","role":"user","content":[{"type":"text","text":"msg 4"}],"attachments":[]},{"id":"turn-2","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r4","reply_message_id":"01a136a5a75979a38a45ec9feb5dacbd"}}],"status":{"type":"complete","reason":"unknown"}}],"nextCursor":"1"}
nextCursor=1
page3: {"messages":[{"id":"u-r1","role":"user","content":[{"type":"text","text":"look"}],"attachments":[{"id":"stored-0","type":"image","name":"image","contentType":"image/png","status":{"type":"complete"},"content":[{"type":"image","image":"http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png","filename":null}]}]},{"id":"turn-0","role":"assistant","content":[{"type":"text","text":"seen it"},{"type":"data","name":"agentcore_message_committed","data":{"user_message_id":"u-r1","reply_message_id":"01a136a59e047ef58736c95c2f643eff"}}],"status":{"type":"complete","reason":"unknown"}}],"nextCursor":null}
page3 nextCursor=None
bad cursor: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"'abc' is not a cursor. Send back the nextCursor a page answered with, or nothing at all."} [400]
limit=999 count of user msgs: 5

=== S7: second MapAgUi call (/custom/{entry}) did not duplicate thread routes ===
turns via shared route: 200
run on /custom/main: 200
Evidence: Driver script
#!/usr/bin/env bash
B=http://127.0.0.1:5199
PNG=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==
T=thread-live-1
run() { # user body
  curl -s -N -H "x-user: $1" -H 'content-type: application/json' -H 'accept: text/event-stream' -X POST "$B/agui/main" -d "$2"; }
body() { # thread runId contentJSON
  echo "{\"threadId\":\"$1\",\"runId\":\"$2\",\"messages\":[{\"id\":\"u-$2\",\"role\":\"user\",\"content\":$3}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}"; }
img() { echo "{\"type\":\"image\",\"source\":{\"type\":\"data\",\"value\":\"$PNG\",\"mimeType\":\"image/png\"}}"; }

for t in $T t-cap t-csv t-url t-custom; do curl -s -X POST $B/demo/create/$t >/dev/null; done
echo "=== S1: alice uploads an inline image on /agui/main (first run) ==="
run alice "$(body $T r1 "[{\"type\":\"text\",\"text\":\"look\"},$(img)]")"
echo; echo "--- what the model received:"; curl -s $B/demo/lastrequest; echo
sleep 1
echo; echo "=== S2: reload history (GET turns) as alice ==="
TURNS=$(curl -s -H 'x-user: alice' "$B/agui/threads/$T/turns"); echo "$TURNS"
REF=$(echo "$TURNS" | python3 -c 'import sys,json;d=json.load(sys.stdin);u=[m for m in d["messages"] if m["role"]=="user"][0];print(u["attachments"][0]["content"][0]["image"])' 2>/dev/null || echo "$TURNS" | grep -o "http[^\"]*/files/[^\"]*" | head -1)
echo "reference = $REF"
echo; echo "=== S3: open the file door + link as alice and as bob ==="
echo -n "alice door: "; curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' -H 'x-user: alice' "$REF"
echo -n "alice link: "; curl -s -w ' [%{http_code}]\n' -H 'x-user: alice' "$REF/link"
echo -n "bob door:   "; curl -s -o /dev/null -w '%{http_code}\n' -H 'x-user: bob' "$REF"
echo -n "bob link:   "; curl -s -o /dev/null -w '%{http_code}\n' -H 'x-user: bob' "$REF/link"
echo -n "bob turns:  "; curl -s -o /dev/null -w '%{http_code}\n' -H 'x-user: bob' "$B/agui/threads/$T/turns"
echo; echo "=== S4: trimmed client body: second run sends ONLY the new message, re-using the file by its reference ==="
run alice "$(body $T r2 "[{\"type\":\"text\",\"text\":\"again\"},{\"type\":\"image\",\"source\":{\"type\":\"url\",\"value\":\"$REF\",\"mimeType\":\"image/png\"}}]")"
echo; echo "--- what the model received:"; curl -s $B/demo/lastrequest; echo
sleep 1
echo; echo "=== S5: guards ==="
echo "-- 5 images (cap is 4):"; run alice "$(body t-cap r1 "[$(img),$(img),$(img),$(img),$(img)]")"; echo
CSV=$(printf 'a,b\n1,2\n' | base64 -w0)
echo "-- text/csv file:"; run alice "$(body t-csv r1 "[{\"type\":\"document\",\"source\":{\"type\":\"data\",\"value\":\"$CSV\",\"mimeType\":\"text/csv\"}}]")"; echo
echo "-- 5 references to the stored file (cap is 4):"; R="{\"type\":\"image\",\"source\":{\"type\":\"url\",\"value\":\"$REF\",\"mimeType\":\"image/png\"}}"
run alice "$(body $T r3 "[$R,$R,$R,$R,$R]")"; echo
echo "-- a foreign url source:"; run alice "$(body t-url r1 "[{\"type\":\"image\",\"source\":{\"type\":\"url\",\"value\":\"https://evil.example/x.png\",\"mimeType\":\"image/png\"}}]")"; echo
echo "-- bob runs a turn on alice's thread:"; curl -s -w ' [%{http_code}]\n' -H 'x-user: bob' -H 'content-type: application/json' -H 'accept: text/event-stream' -X POST "$B/agui/main" -d "$(body $T r9 '[{"type":"text","text":"hi"}]')"
echo; echo "=== S6: paging the turns route ==="
for i in 4 5 6; do run alice "$(body $T r$i "[{\"type\":\"text\",\"text\":\"msg $i\"}]")" >/dev/null; sleep 0.5; done
P1=$(curl -s -H 'x-user: alice' "$B/agui/threads/$T/turns?limit=2"); echo "page1: $P1"
C=$(echo "$P1" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("nextCursor"))'); echo "nextCursor=$C"
P2=$(curl -s -H 'x-user: alice' "$B/agui/threads/$T/turns?limit=2&before=$C"); echo "page2: $P2"
C2=$(echo "$P2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("nextCursor"))'); echo "nextCursor=$C2"
P3=$(curl -s -H 'x-user: alice' "$B/agui/threads/$T/turns?limit=2&before=$C2"); echo "page3: $P3"
echo "page3 nextCursor=$(echo "$P3" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("nextCursor"))')"
echo -n "bad cursor: "; curl -s -w ' [%{http_code}]\n' -H 'x-user: alice' "$B/agui/threads/$T/turns?before=abc"
echo -n "limit=999 count of user msgs: "; curl -s -H 'x-user: alice' "$B/agui/threads/$T/turns?limit=999" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(sum(m["role"]=="user" for m in d["messages"]))'
echo; echo "=== S7: second MapAgUi call (/custom/{entry}) did not duplicate thread routes ==="
echo -n "turns via shared route: "; curl -s -o /dev/null -w '%{http_code}\n' -H 'x-user: alice' "$B/agui/threads/$T/turns"
echo -n "run on /custom/main: "; curl -s -o /dev/null -w '%{http_code}\n' -H 'x-user: alice' -H 'content-type: application/json' -H 'accept: text/event-stream' -X POST "$B/custom/main" -d "$(body t-custom r1 '[{"type":"text","text":"hi"}]')"
Evidence: Disposable host source
using System.Runtime.CompilerServices;
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Hooks;
using AgentCore.Application.Hooks.Gates;
using AgentCore.AspNetCore.DependencyInjection;
using AgentCore.AspNetCore.Endpoints;
using AgentCore.TestSupport;
using Microsoft.Extensions.AI;
using DemoHost;

const string Yaml = """
apiVersion: agentcore/v1
agents:
  defaults:
    model: { ref: reply }
  items:
    - { id: greeter, instructions: "greet the caller" }
providers:
  conversation:   { kind: telnyx-relay }
  speech:
    stt: { kind: telnyx-relay }
    tts: { kind: telnyx-relay }
  llm:
    - { kind: openai, model: gpt-4.1-mini, as: reply }
  blobs: { kind: fake }
entries:
  main:
    agent: greeter
  other:
    agent: greeter
""";

var builder = WebApplication.CreateSlimBuilder(args);
builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("DEMO_URL") ?? "http://127.0.0.1:5199");
var echo = new EchoClient();
builder.Services.AddAgentCore(o =>
{
    o.Configuration = ConfigurationLoader.LoadYaml(Yaml);
    _ = o.UseChatClients(_ => new RoutingChatClientFactory(echo));
    _ = o.UseBlobStores(new FakeBlobStoreAdapter()).UseHooks(new OwnerHook());
});
var app = builder.Build();
app.MapAgUi();
app.MapAgUi("/custom/{entry}");   // second call: thread routes must not be duplicated
app.MapPost("/demo/create/{id}", async (string id, AgentCore.Application.Ports.IConversations c) => { await c.CreateAsync(id, default); return Results.Ok(); });
app.MapGet("/demo/lastrequest", () => Results.Text(echo.Describe()));
app.Run();

sealed class OwnerHook : AgentHook
{
    public override ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken ct) { gate.Choose("main"); return default; }
    public override ValueTask BeforeConversationAsync(ConversationGate gate, CancellationToken ct)
    {
        if (!gate.Headers.TryGetValue("x-user", out string? u) || u != "alice") gate.Refuse();
        return default;
    }
}

sealed class EchoClient : IChatClient
{
    private IReadOnlyList<ChatMessage>? _last;
    public string Describe() => _last is null ? "none" : string.Join("\n", _last.Select(m => $"{m.Role}: " + string.Join(" | ", m.Contents.Select(c => c switch
    {
        TextContent t => "text(" + t.Text + ")",
        DataContent d => $"data({d.MediaType},{d.Data.Length} bytes)",
        UriContent u => $"uri({u.Uri})",
        _ => c.GetType().Name,
    }))));
    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken ct = default)
    {
        _last = [.. messages];
        await Task.Yield();
        yield return new ChatResponseUpdate(ChatRole.Assistant, "seen it") { MessageId = Guid.NewGuid().ToString("N") };
    }
    public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken ct = default)
        => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{}")));
    public object? GetService(Type t, object? k = null) => null;
    public void Dispose() { }
}
Evidence: Host server log
info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[62]
      User profile is available. Using '~/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
warn: AgentCore.Application.Conversation.Memory.InMemoryConversationStore[3]
      the document names no providers.conversations, so a conversation's row and the words of every conversation are kept in this process. That store is not durable, it grows without a bound, and no retention window applies to it. Name a durable providers.conversations.kind, or write kind: memory to say this was meant.
warn: AgentCore.Application.Audit.QueuedAuditSink[1]
      the document names no providers.audit, so the audit chain of D23 is kept in this process. That store is not durable and it grows without a bound. Name a durable providers.audit.kind, or write kind: memory to say this was meant.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://127.0.0.1:5199
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: ~/.no-mistakes/evidence/01M4V900AR2N8T668JPQ4FJ1H1/host
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/ - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/ - 404 0 - 39.5944ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[16]
      Request reached the end of the middleware pipeline without being handled by application code. Request path: GET http://127.0.0.1:5199/, Response status code: 404
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/demo/create/thread-live-1 - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
      Setting HTTP status code 200.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/demo/create/thread-live-1 - 200 0 - 10.7863ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-cap - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
      Setting HTTP status code 200.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-cap - 200 0 - 6.8949ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-csv - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
      Setting HTTP status code 200.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-csv - 200 0 - 0.6915ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-url - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
      Setting HTTP status code 200.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-url - 200 0 - 13.7669ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-custom - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
      Setting HTTP status code 200.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /demo/create/{id}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/demo/create/t-custom - 200 0 - 0.3244ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/agui/main - application/json 340
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/agui/main - 200 - text/event-stream 433.5338ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/demo/lastrequest - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /demo/lastrequest'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of text/plain; charset=utf-8
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /demo/lastrequest'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/demo/lastrequest - 200 140 text/plain;+charset=utf-8 4.1046ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns - 200 6

... [9714 bytes truncated] ...


info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/agui/main - 200 - text/event-stream 21.3614ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/agui/main - application/json 169
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/agui/main - 200 - text/event-stream 1.9436ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/agui/main - application/json 169
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /agui/{entry}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/agui/main - 200 - text/event-stream 1.8901ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2 - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2 - 200 745 application/json 9.0703ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2&before=3 - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2&before=3 - 200 981 application/json 1.1432ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2&before=1 - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=2&before=1 - 200 624 application/json 0.5036ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?before=abc - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult[1]
      Setting HTTP status code 400.
info: Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult[3]
      Writing value of type 'ProblemDetails' as Json.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?before=abc - 400 - application/problem+json 13.3185ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=999 - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns?limit=999 - 200 2288 application/json 0.7604ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns - - -
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Http.Result.ContentResult[2]
      Write content with HTTP Response ContentType of application/json
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /agui/threads/{threadId}/turns'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET http://127.0.0.1:5199/agui/threads/thread-live-1/turns - 200 2288 application/json 0.7375ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://127.0.0.1:5199/custom/main - application/json 161
info: AgentCore.AspNetCore.Voice.Routing.CallerAuthenticationHandler[7]
      AgentCore.Caller was not authenticated. Failure message: this host routes no inbound conversation.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST /custom/{entry}'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST /custom/{entry}'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST http://127.0.0.1:5199/custom/main - 200 - text/event-stream 1.4886ms
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
Evidence: Targeted AG-UI test run
Test run for ~/.no-mistakes/worktrees/dfc5454e982a/01M4V900AR2N8T668JPQ4FJ1H1/tests/AgentCore.AspNetCore.Tests/bin/Debug/net10.0/AgentCore.AspNetCore.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.2 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:   104, Skipped:     0, Total:   104, Duration: 5 s - AgentCore.AspNetCore.Tests.dll (net10.0)
Evidence: Key live results
alice door: 302 -> https://blobs.test/thread-live-1/in-4eaddd5fddf64364.png?ttl=300
alice link: {"url":...,"expiresAt":...} [200]
bob door: 404 / bob link: 404 / bob turns: 404
trimmed run model input: user: text(again) | data(image/png,70 bytes)
5 refs -> attachment_too_many; text/csv -> attachment_type_not_allowed
bad cursor -> 400 "Send back the nextCursor a page answered with, or nothing at all."

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 warning
  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiAttachments.cs:53 - The round-1 fix decision F1 is not done yet. Check still counts and type-checks only DataContent and UriContent. AgUiFileRefs.ResolveAsync (AgUiFileRefs.cs:123-133) and StandInStoredFilesAsync (AgUiEndpoints.cs:287-317) both swap in FileContent before the check at AgUiEndpoints.cs:158. As a result, a message with many references gets past MaxParts=4. An agent-published file of a type that is not on the allow-list (for example text/csv) is restamped as an upload: file. InboundFiles.RehydrateAsync then sends it to the model on every later turn. Fix as decided: in Check, count FileContent toward parts, add its Length to total, and check its MediaType against AllowedMediaTypes.
  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs:240 - The round-1 fix decision F2 is not done yet. OwnedAsync finds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. The default MapAgentCoreHost setup uses /agui/{entry} with 2 or more entries and no hook. On that setup the file door (line 212), the link route (line 179), and /agui/threads/{id}/turns (AgUiTurnHistory.cs:35) always answer 404. The references the stream mints (AgUiEndpoints.cs:263) and the history mints therefore never open. Fix as decided: keep the reference shape. Load the conversation's own stored record. Return the existing not-found answer when the conversation is unknown. Take the entry from the stored record. Then run AgentCoreConversationGate.AllowAsync with that entry, as a turn does. Add the required tests: a host with 2+ entries and no hook can open its own door, link, and turns; another user's conversation is still refused.
  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiEndpoints.cs:54 - The round-1 fix decision F3 is not done yet. Each MapAgUi(pattern) call maps the global GET routes again (lines 54-56: link, door, turns). If a host calls MapAgUi twice (for example MapAgentCoreHost plus its own MapAgUi(&#34;/custom&#34;)), every file, link, and turns request fails with an AmbiguousMatchException (HTTP 500). Fix as decided: map the 3 thread routes only once per route builder.
  • ℹ️ src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs:40 - The round-1 fix decision F4 is not done yet. The turns route still loads new TranscriptWindow(null, 100) and has no paging. On a thread with more than 100 turns, the older turns are not shown, and nothing tells the browser that they exist. Fix as decided: add an optional before query value (parsed with int.TryParse, NumberStyles.None, invariant culture) and an optional limit (default 30, clamped to 1..100). Build TranscriptWindow(before, limit). Return a nextCursor from StoredConversation.OlderBefore, or null when there are no older turns. An unreadable cursor gets a 400 that says to send back nextCursor or nothing at all. Add the required tests: first page, next page, last page, bad cursor, and limit clamp.
  • ℹ️ src/AgentCore.AspNetCore/Endpoints/AgUiInput.cs:19 - The round-1 fix decision F5 is not done yet. AgUiInput.LastUserInput only does a null check and then calls AgUiAttachments.LastUserInput (AgUiAttachments.cs:36). That makes 2 definitions of the same rule. Callers: AgUiEndpoints.cs:115 and AgUiApprovals.cs:45. Fix as decided: keep 1 definition and call it directly.

🔧 Fix applied.
4 issues (3 warnings, 1 info) still open:

  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs:240 - The round-1 fix decision F2 is not done yet. OwnedAsync finds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. The default MapAgentCoreHost setup uses /agui/{entry} with 2 or more entries and no hook. On that setup the file door (line 212), the link route (line 179), and /agui/threads/{id}/turns (AgUiTurnHistory.cs:35) always answer 404. The references the stream mints (AgUiEndpoints.cs:263) and the history mints therefore never open. Fix as decided: keep the reference shape. Load the conversation's own stored record. Return the existing not-found answer when the conversation is unknown. Take the entry from the stored record. Then run AgentCoreConversationGate.AllowAsync with that entry, as a turn does. Add the required tests: a host with 2+ entries and no hook can open its own door, link, and turns; another user's conversation is still refused.
  • ℹ️ src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs:40 - The round-1 fix decision F4 is not done yet. The turns route still loads new TranscriptWindow(null, 100) and has no paging. On a thread with more than 100 turns, the older turns are not shown, and nothing tells the browser that they exist. Fix as decided: add an optional before query value (parsed with int.TryParse, NumberStyles.None, invariant culture) and an optional limit (default 30, clamped to 1..100). Build TranscriptWindow(before, limit). Return a nextCursor from StoredConversation.OlderBefore, or null when there are no older turns. An unreadable cursor gets a 400 that says to send back nextCursor or nothing at all. Add the required tests: first page, next page, last page, bad cursor, and limit clamp.
  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs:240 - The round-1 human decision F2 is still not done. The fix round left OwnedAsync unchanged. It still finds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. On the default MapAgentCoreHost setup (/agui/{entry}, 2 or more entries, no hook), these routes still always answer 404: the file door (AgUiFileRefs.cs:212), the link route (AgUiFileRefs.cs:179), and the turns route (AgUiTurnHistory.cs:43, including the new paging). The F2 tests (2+ entries, no hook, own conversation opens; another user's conversation is refused) are also missing. The decision says to take the entry from the conversation's own stored record. But no stored record holds an entry: ConversationRecord (ConversationRecord.cs:26-35) and ConversationSessionState (fields: Version, NextTurnIndex, Stage, IsComplete, Slots, Clarifications, Providers, WorkflowState) have no entry field. So the smallest honest fix adds new durable state: an entry column or field, written when the conversation is created or runs its first turn, plus a store/schema change across the Postgres and in-memory adapters. The defect itself is clear. The remedy is the part that needs approval. Options: (a) add an entry field to the conversation record and read it in OwnedAsync (recommended; it matches the decision); (b) for an entry-less thread route, run the G-7 gate once per declared entry and allow if any entry allows.
  • ⚠️ src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs:61 - The new paging (fix round for F4) can split an approval pair across 2 pages. The same tool call then shows twice, and the older copy looks unfinished. The class doc (lines 19-22) says an approved call is stored twice: the asking turn N stores a ToolApprovalRequestContent, and resuming turn N+1 stores the same FunctionCallContent plus its result. The asked set (line 75) and the results map (lines 69-72) are built only from the rows in the current window. Example: default limit 30, approval asked in turn 9, resumed in turn 10, page starts at turn 10. Page 1: turn 10's call is not in asked, so it is emitted as a tool-call with a result. Page 2 (before=10): turn 9 emits the same toolCallId from the approval request. The result is in turn 10, outside this window, and pending no longer holds the call, so the call has no result and no approval. The client gets a duplicate toolCallId and a call that never seems to finish. Before this round, the window was 100 turns, so this happened only past 100 turns. Now the default is 30, so it happens much more often. Fix: build asked and results from a wider read, for example one turn past each edge of the page (TranscriptWindow(before + 1, limit + 2)), and emit only the turns inside the page.

🔧 Fix applied.
1 warning still open:

  • ⚠️ src/AgentCore.Application/Runtime/Session/ConversationSessionStateStore.cs:122 - The last fix round (F2/review-1, option a) stores the entry only in the turn-end state snapshot. OwnedAsync (AgUiFileRefs.cs:246-265) reads it from record.State?.Entry. But the conversation row exists from OpenSessionAsync -> CreateAsync (ConversationTranscriptLedger.cs:196), and the state lands only with the turn's commit append (ConversationWrites.cs:39; mid-turn appends at :53 and :206 pass state: null). During the whole first turn, and until the queued commit write lands, record.State is null. On a host with 2+ entries and no BeforeEntry hook, OwnedAsync then takes the legacy path and answers 404. Concrete sequence: the first run on /agui/main uploads an image or publishes a file. The stream mints its reference (AgUiEndpoints.cs:270). The browser swaps the reference through /agui/threads/{id}/files/{name}/link while the turn is still running. It gets 404, even though the blob already exists (the door checks only the blob store). The same gap applies to the file door (AgUiFileRefs.cs:212) and to the turns route (AgUiTurnHistory.cs:43). The new test AgUiThreadOwnerTests waits for the rows to settle before it opens the routes (StoredRows.SettleAsync), so it does not catch this. The decision allowed 'written when the conversation is created (or on its first turn...)'. The write point that closes the gap is conversation creation (CreateAsync keeps the entry), or the first append of the first turn carries the state. Either one changes the store port or the write path. So the choice of write point needs approval; the defect itself does not.

🔧 Fix applied.
1 warning still open:

  • ⚠️ src/AgentCore.Application/Runtime/Session/ConversationSessionStateStore.cs:122 - The last fix round (F2/review-1, option a) stores the entry only in the turn-end state snapshot. OwnedAsync (AgUiFileRefs.cs:246-265) reads it from record.State?.Entry. But the conversation row exists from OpenSessionAsync -> CreateAsync (ConversationTranscriptLedger.cs:196), and the state lands only with the turn's commit append (ConversationWrites.cs:39; mid-turn appends at :53 and :206 pass state: null). During the whole first turn, and until the queued commit write lands, record.State is null. On a host with 2+ entries and no BeforeEntry hook, OwnedAsync then takes the legacy path and answers 404. Concrete sequence: the first run on /agui/main uploads an image or publishes a file. The stream mints its reference (AgUiEndpoints.cs:270). The browser swaps the reference through /agui/threads/{id}/files/{name}/link while the turn is still running. It gets 404, even though the blob already exists (the door checks only the blob store). The same gap applies to the file door (AgUiFileRefs.cs:212) and to the turns route (AgUiTurnHistory.cs:43). The new test AgUiThreadOwnerTests waits for the rows to settle before it opens the routes (StoredRows.SettleAsync), so it does not catch this. The decision allowed 'written when the conversation is created (or on its first turn...)'. The write point that closes the gap is conversation creation (CreateAsync keeps the entry), or the first append of the first turn carries the state. Either one changes the store port or the write path. So the choice of write point needs approval; the defect itself does not.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Caller uploads an inline PNG on /agui/main; the run streams a reply and the model gets the image bytes ✅ pass live drive-transcript.txt S1: RUN_STARTED..RUN_FINISHED success; model input 'user: text(look) | data(image/png,70 bytes)'
Reloading history (GET /agui/threads/{id}/turns) shows the file as a stable absolute reference, not inline bytes ✅ pass live drive-transcript.txt S2: attachment image = http://127.0.0.1:5199/agui/threads/thread-live-1/files/in-4eaddd5fddf64364.png
Owner opens the file door (302 to the blob link) and the link route (200 JSON url+expiresAt). Another user gets 404 on the door, link and turns (2-entry host with BeforeEntry hook) ✅ pass live drive-transcript.txt S3
Trimmed client body: a second run sends only the new message and re-uses the file by its reference; the model gets full history plus the image bytes ✅ pass live drive-transcript.txt S4: model input has turn 1 + 'user: text(again) | data(image/png,70 bytes)'
Adversarial guards: 5 images, a text/csv file, 5 references to a stored file, a foreign URL, and a non-owner run are all refused before the model ✅ pass live drive-transcript.txt S5: attachment_too_many (both cases), attachment_type_not_allowed, attachment_url_not_allowed, thread_not_found
Turns paging: limit=2 pages chain through nextCursor to a last page with no cursor; before=abc gives 400 with the send-back-nextCursor message; limit=999 is clamped and returns all 5 turns ✅ pass live drive-transcript.txt S6
Host calls MapAgUi twice (default plus /custom/{entry}); thread routes still answer 200 (no ambiguous-route 500) and the second pattern runs turns ✅ pass live drive-transcript.txt S7: turns 200, /custom/main 200
  • dotnet build tests/AgentCore.AspNetCore.Tests (0 warnings, 0 errors)
  • dotnet test tests/AgentCore.AspNetCore.Tests --filter &#34;FullyQualifiedName~AgUi|FullyQualifiedName~TurnStreamFiles&#34; (104 passed, real Kestrel socket, fake model)
  • dotnet test tests/AgentCore.Application.Tests --filter &#34;FullyQualifiedName~InboundFiles|FullyQualifiedName~ConversationSessionCarry&#34; (8 passed)
  • Built a throwaway ASP.NET host in the evidence dir (AddAgentCore + MapAgUi() + MapAgUi("/custom/{entry}"), 2 entries, BeforeEntry hook, owner-only BeforeConversation hook, in-memory blob store, echo model). Ran it on 127.0.0.1:5199 and drove it with curl through drive.sh
  • Live: inline PNG upload run, history reload, file door and link as owner and as another user, trimmed body re-using the reference, attachment caps and allow-list, foreign URL refusal, turns paging and bad cursor, limit clamp, route under the second MapAgUi call
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@MatthewHsu1
MatthewHsu1 merged commit 0c6f06c into main Oct 13, 2026
1 check passed
@MatthewHsu1
MatthewHsu1 deleted the fm/agui-phase3 branch October 13, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant