Repository navigation
feat(aspnetcore): add AG-UI endpoint with approvals, inbound files, and turn history - #41
Merged
Merged
Conversation
…re-less file listing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) andfeat/agui-endpoint-phase2(e11eea9), not pushed. Phase 3 is in progress and uncommitted in the main AgentCore folder. Its plan isdocs/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
MapAgUi, defaultPOST /agui/{entry}) built on theAGUI.*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 newBeforeConversationAsynchook gate (ConversationGate,GatePoint.BeforeConversation,EntryTransport.AgUi).MapAgentCoreHostnow maps it too, through a newagUiPatternparameter and anAgUiproperty onAgentCoreEndpoints.InboundFiles.FileAsync/RehydrateAsync, with anupload: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, andBlobRefgets an optionalDownloadName. Trimmed client bodies that leave out file bytes get their files restored from the stored user row.PendingApprovals.ReadandConversationTurnOrigin.KeepsWithdrawnTools, plus new tests for every AG-UI piece and an updated README.Risk Assessment
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.
Evidence: Live curl transcript against the running host
Evidence: Driver script
Evidence: Disposable host source
Evidence: Host server log
Evidence: Targeted AG-UI test run
Evidence: Key live results
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
src/AgentCore.AspNetCore/Endpoints/AgUiAttachments.cs:53- The round-1 fix decision F1 is not done yet.Checkstill counts and type-checks onlyDataContentandUriContent.AgUiFileRefs.ResolveAsync(AgUiFileRefs.cs:123-133) andStandInStoredFilesAsync(AgUiEndpoints.cs:287-317) both swap inFileContentbefore 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 anupload:file.InboundFiles.RehydrateAsyncthen sends it to the model on every later turn. Fix as decided: inCheck, countFileContenttowardparts, add itsLengthtototal, and check itsMediaTypeagainstAllowedMediaTypes.src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs:240- The round-1 fix decision F2 is not done yet.OwnedAsyncfinds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. The defaultMapAgentCoreHostsetup 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 runAgentCoreConversationGate.AllowAsyncwith 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. EachMapAgUi(pattern)call maps the global GET routes again (lines 54-56: link, door, turns). If a host callsMapAgUitwice (for exampleMapAgentCoreHostplus its ownMapAgUi("/custom")), 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 loadsnew 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 optionalbeforequery value (parsed with int.TryParse, NumberStyles.None, invariant culture) and an optionallimit(default 30, clamped to 1..100). BuildTranscriptWindow(before, limit). Return anextCursorfromStoredConversation.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.LastUserInputonly does a null check and then callsAgUiAttachments.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.OwnedAsyncfinds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. The defaultMapAgentCoreHostsetup 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 runAgentCoreConversationGate.AllowAsyncwith 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 loadsnew 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 optionalbeforequery value (parsed with int.TryParse, NumberStyles.None, invariant culture) and an optionallimit(default 30, clamped to 1..100). BuildTranscriptWindow(before, limit). Return anextCursorfromStoredConversation.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 leftOwnedAsyncunchanged. It still finds an entry only from a BeforeEntry hook or when the host declares exactly 1 entry. On the defaultMapAgentCoreHostsetup (/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) andConversationSessionState(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. Theaskedset (line 75) and theresultsmap (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 inasked, 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, andpendingno 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: buildaskedandresultsfrom 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 fromrecord.State?.Entry. But the conversation row exists fromOpenSessionAsync->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 passstate: null). During the whole first turn, and until the queued commit write lands,record.Stateis null. On a host with 2+ entries and no BeforeEntry hook,OwnedAsyncthen takes the legacy path and answers 404. Concrete sequence: the first run on/agui/mainuploads 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}/linkwhile 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 testAgUiThreadOwnerTestswaits 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 (CreateAsynckeeps 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 fromrecord.State?.Entry. But the conversation row exists fromOpenSessionAsync->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 passstate: null). During the whole first turn, and until the queued commit write lands,record.Stateis null. On a host with 2+ entries and no BeforeEntry hook,OwnedAsyncthen takes the legacy path and answers 404. Concrete sequence: the first run on/agui/mainuploads 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}/linkwhile 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 testAgUiThreadOwnerTestswaits 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 (CreateAsynckeeps 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.
dotnet build tests/AgentCore.AspNetCore.Tests(0 warnings, 0 errors)dotnet test tests/AgentCore.AspNetCore.Tests --filter "FullyQualifiedName~AgUi|FullyQualifiedName~TurnStreamFiles"(104 passed, real Kestrel socket, fake model)dotnet test tests/AgentCore.Application.Tests --filter "FullyQualifiedName~InboundFiles|FullyQualifiedName~ConversationSessionCarry"(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 throughdrive.shLive: 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.