docs: research AG-UI and MCP Apps frameworks and showcase opportunities - #259
docs: research AG-UI and MCP Apps frameworks and showcase opportunities#259NickB03 wants to merge 10 commits into
Conversation
Add research doc mapping the AG-UI (Agent-User Interaction) protocol and the MCP Apps (SEP-1865) / mcp-ui frameworks against Polymorph's existing generative-UI, streaming, and tool-UI architecture. Identifies three prioritized showcase initiatives (AG-UI server endpoint, MCP Apps host, MCP Apps server) with concrete codebase integration points and a sequencing recommendation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
Add POST /api/agui — an AG-UI (Agent-User Interaction protocol) endpoint that runs Polymorph's existing chat agent and streams the result as AG-UI events over SSE, letting any AG-UI-compatible frontend (e.g. CopilotKit) drive the agent. This is Initiative B from docs/research/AGUI-MCP-APPS.md. - lib/streaming/agui/adapter.ts: pure mapping — AG-UI RunAgentInput messages to AI SDK ModelMessage[], and AI SDK fullStream parts to AG-UI events (text/tool/step), with tool-call lifecycle dedup. - lib/streaming/agui/response.ts: builds the agent statelessly (no DB/canvas), wraps the stream in RUN_STARTED/RUN_FINISHED/RUN_ERROR, encodes via @ag-ui/encoder for spec-compliant SSE framing. - app/api/agui/route.ts: validates RunAgentInput with @ag-ui/core; gated behind ENABLE_AGUI_ENDPOINT (off by default — runs unauthenticated/stateless). - Unit tests for the mapping layer; docs at docs/architecture/AGUI-ENDPOINT.md; env flag documented in .env.local.example. Adds @ag-ui/core and @ag-ui/encoder dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
…rror mapping
- Split the agent-free SSE/lifecycle encoder into lib/streaming/agui/sse.ts
(aguiSseResponse) so it can be unit-tested without loading the model/agent
stack (importing the registry chain broke vitest collection via esbuild).
- Add sse.test.ts: verifies RUN_STARTED/RUN_FINISHED wrapping, event ordering,
tool-call lifecycle dedup, and RUN_ERROR on both start-time and mid-stream
failures, decoding real SSE frames.
- Fix: safeStringify now surfaces Error.message instead of '{}', so tool-error
parts carry the real error text in TOOL_CALL_RESULT (caught by a new test).
- Add tool-error mapping test; update endpoint doc file map.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
Research-backed implementation plan for hosting MCP Apps in Polymorph. Key finding: AI SDK v6 ships stable first-party MCP (@ai-sdk/mcp) plus an official host renderer (experimental_MCPAppRenderer), so the sandboxed iframe + JSON-RPC bridge are provided rather than hand-rolled. Recommends a dedicated runtime allow-list of remote Streamable-HTTP MCP App servers (not the dev-only .mcp.json, which is stdio), maps the integration onto existing seams (createChatAgentTools, DynamicToolDisplay, useChat), and lays out a gated, phased plan with an explicit security model. No code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
… mode Make the AG-UI endpoint demonstrably fully working end-to-end without an API key. - sse.ts: an 'error' fullStream part is now terminal — emit RUN_ERROR and stop, with no trailing RUN_FINISHED (a run ends in exactly one terminal event). The catch-based RUN_ERROR for thrown errors is unchanged. - agent.test.ts: end-to-end test driving a real ToolLoopAgent backed by MockLanguageModelV3 (no API key) through aguiSseResponse, asserting the full ordered lifecycle incl. a complete tool call + TOOL_CALL_RESULT. - demo.ts + response.ts: gated AGUI_DEMO mode (AGUI_DEMO=true and not a production target) streams a scripted, model-free lifecycle so the endpoint can be exercised with no credentials. Verified in a browser: the full RUN_STARTED -> text -> TOOL_CALL_* + result -> RUN_FINISHED sequence renders from the live SSE stream. - sse.test.ts: terminal-error test; docs updated for both behaviors + demo mode. Gates: 15/15 agui tests, bun typecheck, bun lint all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
Two directions completing the AG-UI integration:
MAPPING (display tools -> AG-UI generative UI):
- adapter.ts: a tool-call for a Polymorph display tool (name derived from
TOOL_UI_TOOL_METADATA, the single source of truth) now also emits a CUSTOM
event { name: 'GenerativeUI', value: { component, toolCallId, kind, props } }
alongside the TOOL_CALL_* lifecycle, so AG-UI frontends can render the matching
component. Emitted even when the tool-call input was streamed (deduped),
without re-emitting the lifecycle. Non-UI tools emit no such event.
- demo.ts: scripted stream now also performs a displayPlan call so the demo
exercises generative UI (browser-confirmed: CUSTOM GenerativeUI event appears).
CONSUMING (Polymorph as an AG-UI client):
- client.ts: consumeAguiStream(source) decodes an AG-UI SSE stream (Response /
ReadableStream / async-iterable) via @ag-ui/core (no new dep) and reduces it
into normalized assistant messages, tool calls, generative-UI components, and
run status. The inverse of aguiSseResponse.
- client.test.ts: loopback round-trip (aguiSseResponse(demoFullStream()) ->
consumeAguiStream) reconstructs text, tool call, result, the displayPlan
generative-UI component, status 'finished'; plus a terminal-error case.
docs/architecture/AGUI-ENDPOINT.md updated (GenerativeUI mapping + Consuming
section). Gates: 20/20 agui tests, bun typecheck, bun lint all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
Close the loop: a consumed AG-UI stream now renders as real Polymorph display
components, not raw event data.
- components/agui/agui-generative-ui.tsx: renders an AguiConsumeResult —
assistant text, reconstructed tool calls, and each generativeUI item mapped
through the tool-UI registry (isRegisteredToolUI + tryRenderToolUIByName), with
a labeled fallback card when a component is unregistered or its props fail the
registry's schema validation (no crash).
- components/agui/use-agui-agent.ts: useAguiAgent({ endpoint }) — POSTs a
RunAgentInput and pipes the Response through consumeAguiStream into React state.
- demo.ts: displayPlan payload is now a valid SerializablePlan ({ id, title,
todos[...] } with real status values) so the registry renders the actual Plan;
client.test.ts assertions updated to match.
- components/agui/agui-generative-ui.test.tsx: consume the demo stream and assert
the REAL Plan renders (title + a todo label) and the fallback is absent; plus
an unregistered-component fallback case.
- app/agui-demo/page.tsx: gated dev demo page driving useAguiAgent against
/api/agui (browser-verified: the live Plan renders from the consumed stream).
- docs updated: Frontend wiring section now documents the implemented components.
Gates: 22/22 agui+component tests, bun typecheck, bun lint all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
More reviews will be available in 9 minutes. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds an AG-UI protocol pipeline: a gated ChangesAG-UI Protocol Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea1a83a54f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // assistant message (the one the model was emitting when it called the tool), | ||
| // falling back to a synthetic message if the stream was tool-calls-only. | ||
| if (toolCalls.size > 0) { | ||
| let host = messages.at(-1) |
There was a problem hiding this comment.
Preserve each tool call's owning message
When a run emits more than one assistant message, such as the multi-step ToolLoopAgent flow added in agent.test.ts where the first message calls a tool and a later message gives the final answer, this end-of-stream lookup attaches every reconstructed tool call to only the final message. That makes earlier assistant turns lose their tool calls and renders unrelated calls under the final answer; track the current/parent message when TOOL_CALL_START arrives instead of assigning all calls to messages.at(-1).
Useful? React with 👍 / 👎.
|
|
||
| function* drain(final: boolean): Generator<BaseEvent> { | ||
| let index: number | ||
| while ((index = buffer.indexOf('\n\n')) !== -1) { |
There was a problem hiding this comment.
Accept CRLF-delimited SSE frames
For AG-UI streams emitted with CRLF separators (\r\n\r\n), which are valid SSE and common with HTTP libraries, this delimiter search never drains frames until EOF. At EOF, multiple data: payloads get concatenated into one string and JSON.parse fails, so consumeAguiStream silently drops the whole run and returns an error result. Normalize line endings or detect \r\n\r\n as a frame boundary before parsing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
.env.local.example (1)
123-128: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDocument
AGUI_DEMOnext to the endpoint toggle.
lib/streaming/agui/response.tsadds a second env gate for the scripted demo path, but this example only showsENABLE_AGUI_ENDPOINT. Adding the dev-onlyAGUI_DEMO=trueknob here will make the new demo flow much easier to discover.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.env.local.example around lines 123 - 128, The AG-UI env example currently documents only the endpoint toggle, but the demo path is also gated by AGUI_DEMO in lib/streaming/agui/response.ts. Update the example next to ENABLE_AGUI_ENDPOINT to also mention AGUI_DEMO=true as a dev-only knob, so the scripted demo flow is easy to discover and configure.lib/streaming/agui/response.ts (1)
26-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDerive
VALID_USER_MODESfrom the shared source of truth.This list duplicates
UserModefromlib/types/search.ts:14. If a new mode is added there,resolveUserMode()will silently downgrade valid clients to'search'. Export a shared constant/type guard from@/lib/types/searchand reuse it here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/streaming/agui/response.ts` around lines 26 - 45, VALID_USER_MODES is duplicated in response.ts and can drift from the shared UserMode definition, causing valid forwardedProps.userMode values to fall back to search. Update resolveUserMode to use the shared source of truth from lib/types/search by exporting and reusing a common constant or type guard, and keep the UserMode check centralized so new modes are automatically accepted here.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/api/agui/route.ts`:
- Around line 26-33: The request handler in route.ts parses JSON with req.json()
before any size check, which can buffer an oversized unauthenticated payload in
memory. Add a hard request-size guard in the AGUI route before the req.json()
call in the handler that uses RunAgentInputSchema, and return BAD_REQUEST when
the body exceeds the limit. Also make sure the same limit is enforced at the
gateway so oversized POSTs are rejected before reaching this endpoint.
In `@components/agui/agui-generative-ui.tsx`:
- Around line 95-99: The render path is too restrictive because
`AguiGenerativeUI` only calls `tryRenderToolUIByName()` when
`isRegisteredToolUI(component)` is true, which prevents the registry from
handling additional generic renderers. Update this branch so the registry helper
can make the renderability decision itself, likely by delegating directly
through `tryRenderToolUIByName()` (or the related registry entry point) for
`component`/`props`/`partId` without the extra `isRegisteredToolUI` guard, while
keeping the fallback to `null` only when the registry truly cannot render.
In `@components/agui/use-agui-agent.ts`:
- Around line 44-46: The new run flow in useAguiAgent resets error but leaves
the previous result visible, so clear the stored result at the same time you
call setStatus('running') and setError(null). Update both run paths referenced
by the comment so the state is reset before invoking the AG-UI request,
preventing app/agui-demo/page.tsx from rendering a stale payload after a failed
retry.
In `@lib/streaming/agui/client.ts`:
- Around line 113-132: The SSE frame splitting in drain() only matches "\n\n",
so CRLF-delimited AG-UI frames are not separated correctly. Update the frame
detection in drain() (and any related buffer trimming in the streaming loop) to
handle "\r\n\r\n" as well as "\n\n", so parseFrame() receives one complete
payload per event instead of concatenated JSON.
- Around line 192-254: The tool-call buffering in the AG-UI client is attaching
all calls to messages.at(-1) at the end, which can misattribute earlier calls to
the wrong assistant message. Update the event reducer in client.ts to track
ownership at TOOL_CALL_START by recording the current assistant message (or a
message identifier/host reference) alongside each toolCallId, then use that
stored association when appending calls instead of always using the last
message. Keep the change localized to the toolCalls assembly logic and the final
attachment block so each call is pushed onto the message that emitted it.
In `@lib/streaming/agui/sse.ts`:
- Around line 34-69: The SSE ReadableStream in sse.ts does not stop the upstream
run when the client disconnects because it only defines start() and never
handles cancel(). Add a cancel(reason) handler on the ReadableStream to abort
the same signal passed into startRun(options.abortSignal), and make sure the
stream cleanup prevents further send() calls after cancellation. Use the
existing startRun and controller logic in the stream setup to wire the
disconnect through so the model stream stops as soon as the SSE connection is
dropped.
---
Nitpick comments:
In @.env.local.example:
- Around line 123-128: The AG-UI env example currently documents only the
endpoint toggle, but the demo path is also gated by AGUI_DEMO in
lib/streaming/agui/response.ts. Update the example next to ENABLE_AGUI_ENDPOINT
to also mention AGUI_DEMO=true as a dev-only knob, so the scripted demo flow is
easy to discover and configure.
In `@lib/streaming/agui/response.ts`:
- Around line 26-45: VALID_USER_MODES is duplicated in response.ts and can drift
from the shared UserMode definition, causing valid forwardedProps.userMode
values to fall back to search. Update resolveUserMode to use the shared source
of truth from lib/types/search by exporting and reusing a common constant or
type guard, and keep the UserMode check centralized so new modes are
automatically accepted here.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d986dbc3-7c29-48e2-9d07-a1427ba11128
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
.env.local.exampleapp/agui-demo/page.tsxapp/api/agui/route.tscomponents/agui/agui-generative-ui.test.tsxcomponents/agui/agui-generative-ui.tsxcomponents/agui/use-agui-agent.tsdocs/architecture/AGUI-ENDPOINT.mddocs/research/AGUI-MCP-APPS.mddocs/research/MCP-APPS-HOST-PLAN.mdlib/streaming/agui/adapter.test.tslib/streaming/agui/adapter.tslib/streaming/agui/agent.test.tslib/streaming/agui/client.test.tslib/streaming/agui/client.tslib/streaming/agui/demo.tslib/streaming/agui/response.tslib/streaming/agui/sse.test.tslib/streaming/agui/sse.tspackage.json
| let body: unknown | ||
| try { | ||
| body = await req.json() | ||
| } catch { | ||
| return jsonError('BAD_REQUEST', 'Invalid JSON body', 400) | ||
| } | ||
|
|
||
| const parsed = RunAgentInputSchema.safeParse(body) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Reject oversized bodies before buffering JSON.
await req.json() loads the entire unauthenticated conversation history into memory before any validation. On this endpoint, one oversized POST can tie up a worker or exhaust memory on self-hosted deployments. Add a hard size guard before calling req.json() and mirror that limit at the gateway.
Suggested guard
+const MAX_AGUI_BODY_BYTES = 1024 * 1024
+
export async function POST(req: Request) {
if (process.env.ENABLE_AGUI_ENDPOINT !== 'true') {
return jsonError('NOT_FOUND', 'AG-UI endpoint is not enabled', 404)
}
+ const contentLength = Number(req.headers.get('content-length') ?? '0')
+ if (Number.isFinite(contentLength) && contentLength > MAX_AGUI_BODY_BYTES) {
+ return jsonError('PAYLOAD_TOO_LARGE', 'Request body is too large', 413)
+ }
+
let body: unknown
try {
body = await req.json()📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let body: unknown | |
| try { | |
| body = await req.json() | |
| } catch { | |
| return jsonError('BAD_REQUEST', 'Invalid JSON body', 400) | |
| } | |
| const parsed = RunAgentInputSchema.safeParse(body) | |
| const MAX_AGUI_BODY_BYTES = 1024 * 1024 | |
| let body: unknown | |
| try { | |
| const contentLength = Number(req.headers.get('content-length') ?? '0') | |
| if (Number.isFinite(contentLength) && contentLength > MAX_AGUI_BODY_BYTES) { | |
| return jsonError('PAYLOAD_TOO_LARGE', 'Request body is too large', 413) | |
| } | |
| body = await req.json() | |
| } catch { | |
| return jsonError('BAD_REQUEST', 'Invalid JSON body', 400) | |
| } | |
| const parsed = RunAgentInputSchema.safeParse(body) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/agui/route.ts` around lines 26 - 33, The request handler in route.ts
parses JSON with req.json() before any size check, which can buffer an oversized
unauthenticated payload in memory. Add a hard request-size guard in the AGUI
route before the req.json() call in the handler that uses RunAgentInputSchema,
and return BAD_REQUEST when the body exceeds the limit. Also make sure the same
limit is enforced at the gateway so oversized POSTs are rejected before reaching
this endpoint.
| let rendered: ReactNode = null | ||
| if (isRegisteredToolUI(component)) { | ||
| rendered = tryRenderToolUIByName(component, props, partId) | ||
| } | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Let the registry decide whether a component is renderable.
This guard bypasses tryRenderToolUIByName() for anything not in isRegisteredToolUI(), but the registry helper also supports additional and generic renderers. Those components will incorrectly fall back even though components/tool-ui/registry.tsx can render them.
Suggested fix
- let rendered: ReactNode = null
- if (isRegisteredToolUI(component)) {
- rendered = tryRenderToolUIByName(component, props, partId)
- }
+ const rendered: ReactNode = tryRenderToolUIByName(component, props, partId)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let rendered: ReactNode = null | |
| if (isRegisteredToolUI(component)) { | |
| rendered = tryRenderToolUIByName(component, props, partId) | |
| } | |
| const rendered: ReactNode = tryRenderToolUIByName(component, props, partId) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/agui/agui-generative-ui.tsx` around lines 95 - 99, The render path
is too restrictive because `AguiGenerativeUI` only calls
`tryRenderToolUIByName()` when `isRegisteredToolUI(component)` is true, which
prevents the registry from handling additional generic renderers. Update this
branch so the registry helper can make the renderability decision itself, likely
by delegating directly through `tryRenderToolUIByName()` (or the related
registry entry point) for `component`/`props`/`partId` without the extra
`isRegisteredToolUI` guard, while keeping the fallback to `null` only when the
registry truly cannot render.
| setStatus('running') | ||
| setError(null) | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Clear the previous result before starting a new run.
This resets error but not result. If a second run fails, the UI keeps rendering the last successful AG-UI payload because app/agui-demo/page.tsx shows result independently of status.
Suggested fix
async (input: unknown): Promise<AguiConsumeResult | null> => {
setStatus('running')
setError(null)
+ setResult(null)
@@
} catch (caught) {
const message =
caught instanceof Error ? caught.message : 'AG-UI run failed'
+ setResult(null)
setError(message)
setStatus('error')
return null
}Also applies to: 65-70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/agui/use-agui-agent.ts` around lines 44 - 46, The new run flow in
useAguiAgent resets error but leaves the previous result visible, so clear the
stored result at the same time you call setStatus('running') and setError(null).
Update both run paths referenced by the comment so the state is reset before
invoking the AG-UI request, preventing app/agui-demo/page.tsx from rendering a
stale payload after a failed retry.
| function* drain(final: boolean): Generator<BaseEvent> { | ||
| let index: number | ||
| while ((index = buffer.indexOf('\n\n')) !== -1) { | ||
| const frame = buffer.slice(0, index) | ||
| buffer = buffer.slice(index + 2) | ||
| const event = parseFrame(frame) | ||
| if (event) yield event | ||
| } | ||
| if (final && buffer.trim().length > 0) { | ||
| const event = parseFrame(buffer) | ||
| if (event) yield event | ||
| buffer = '' | ||
| } | ||
| } | ||
|
|
||
| for await (const chunk of sourceToTextChunks(source)) { | ||
| buffer += chunk | ||
| yield* drain(false) | ||
| } | ||
| yield* drain(true) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Handle CRLF-delimited SSE frames.
drain() only looks for '\n\n'. If an AG-UI server sends blank lines as '\r\n\r\n', the buffer never splits into frames; drain(true) then hands multiple JSON payloads to one JSON.parse() and drops the whole stream.
Suggested fix
async function* decodeAguiEvents(
source: AguiStreamSource
): AsyncIterable<BaseEvent> {
let buffer = ''
function* drain(final: boolean): Generator<BaseEvent> {
let index: number
- while ((index = buffer.indexOf('\n\n')) !== -1) {
+ while ((index = buffer.search(/\r?\n\r?\n/)) !== -1) {
const frame = buffer.slice(0, index)
- buffer = buffer.slice(index + 2)
+ const separator = buffer.slice(index).match(/^\r?\n\r?\n/)?.[0] ?? '\n\n'
+ buffer = buffer.slice(index + separator.length)
const event = parseFrame(frame)
if (event) yield event
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function* drain(final: boolean): Generator<BaseEvent> { | |
| let index: number | |
| while ((index = buffer.indexOf('\n\n')) !== -1) { | |
| const frame = buffer.slice(0, index) | |
| buffer = buffer.slice(index + 2) | |
| const event = parseFrame(frame) | |
| if (event) yield event | |
| } | |
| if (final && buffer.trim().length > 0) { | |
| const event = parseFrame(buffer) | |
| if (event) yield event | |
| buffer = '' | |
| } | |
| } | |
| for await (const chunk of sourceToTextChunks(source)) { | |
| buffer += chunk | |
| yield* drain(false) | |
| } | |
| yield* drain(true) | |
| function* drain(final: boolean): Generator<BaseEvent> { | |
| let index: number | |
| while ((index = buffer.search(/\r?\n\r?\n/)) !== -1) { | |
| const frame = buffer.slice(0, index) | |
| const separator = buffer.slice(index).match(/^\r?\n\r?\n/)?.[0] ?? '\n\n' | |
| buffer = buffer.slice(index + separator.length) | |
| const event = parseFrame(frame) | |
| if (event) yield event | |
| } | |
| if (final && buffer.trim().length > 0) { | |
| const event = parseFrame(buffer) | |
| if (event) yield event | |
| buffer = '' | |
| } | |
| } | |
| for await (const chunk of sourceToTextChunks(source)) { | |
| buffer += chunk | |
| yield* drain(false) | |
| } | |
| yield* drain(true) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/streaming/agui/client.ts` around lines 113 - 132, The SSE frame splitting
in drain() only matches "\n\n", so CRLF-delimited AG-UI frames are not separated
correctly. Update the frame detection in drain() (and any related buffer
trimming in the streaming loop) to handle "\r\n\r\n" as well as "\n\n", so
parseFrame() receives one complete payload per event instead of concatenated
JSON.
| case EventType.TOOL_CALL_START: { | ||
| const start = event as ToolCallStartEvent | ||
| toolCalls.set(start.toolCallId, { | ||
| toolCallId: start.toolCallId, | ||
| name: start.toolCallName, | ||
| args: '' | ||
| }) | ||
| break | ||
| } | ||
| case EventType.TOOL_CALL_ARGS: { | ||
| const argsEvent = event as ToolCallArgsEvent | ||
| const call = toolCalls.get(argsEvent.toolCallId) | ||
| if (call) call.args += argsEvent.delta | ||
| break | ||
| } | ||
| case EventType.TOOL_CALL_RESULT: { | ||
| const result = event as ToolCallResultEvent | ||
| const call = toolCalls.get(result.toolCallId) | ||
| if (call) call.result = result.content | ||
| break | ||
| } | ||
|
|
||
| case EventType.CUSTOM: { | ||
| const custom = event as CustomEvent | ||
| if (custom.name === 'GenerativeUI') { | ||
| const value = (custom.value ?? {}) as Partial<AguiGenerativeUi> | ||
| generativeUI.push({ | ||
| component: String(value.component ?? ''), | ||
| toolCallId: String(value.toolCallId ?? ''), | ||
| kind: value.kind, | ||
| props: value.props | ||
| }) | ||
| } | ||
| break | ||
| } | ||
|
|
||
| case EventType.RUN_FINISHED: | ||
| status = 'finished' | ||
| break | ||
| case EventType.RUN_ERROR: | ||
| status = 'error' | ||
| error = (event as RunErrorEvent).message | ||
| break | ||
|
|
||
| // TOOL_CALL_END, TEXT_MESSAGE_END, lifecycle/step events carry no state | ||
| // we need to reduce here. | ||
| default: | ||
| break | ||
| } | ||
| } | ||
|
|
||
| // Attach assembled tool calls to their owning assistant message. AG-UI tool | ||
| // calls aren't tagged with a parent messageId, so attach to the most recent | ||
| // assistant message (the one the model was emitting when it called the tool), | ||
| // falling back to a synthetic message if the stream was tool-calls-only. | ||
| if (toolCalls.size > 0) { | ||
| let host = messages.at(-1) | ||
| if (!host) { | ||
| host = { id: 'assistant', role: 'assistant', text: '', toolCalls: [] } | ||
| messages.push(host) | ||
| } | ||
| host.toolCalls.push(...toolCalls.values()) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Track tool-call ownership when each call starts.
All tool calls are buffered globally and attached once to messages.at(-1) at the end of the run. In any stream with multiple assistant messages, earlier tool calls will be rendered under the last message instead of the message that emitted them.
Suggested fix
const messages: AguiAssistantMessage[] = []
const messagesById = new Map<string, AguiAssistantMessage>()
const toolCalls = new Map<string, AguiToolCall>()
+ const toolCallHosts = new Map<string, AguiAssistantMessage>()
const generativeUI: AguiGenerativeUi[] = []
let status: AguiConsumeResult['status'] = 'error'
let error: string | undefined
+ let currentMessage: AguiAssistantMessage | undefined
@@
case EventType.TEXT_MESSAGE_START:
- messageFor((event as TextMessageStartEvent).messageId)
+ currentMessage = messageFor((event as TextMessageStartEvent).messageId)
break
case EventType.TEXT_MESSAGE_CONTENT: {
const content = event as TextMessageContentEvent
- messageFor(content.messageId).text += content.delta
+ currentMessage = messageFor(content.messageId)
+ currentMessage.text += content.delta
break
}
@@
case EventType.TOOL_CALL_START: {
const start = event as ToolCallStartEvent
- toolCalls.set(start.toolCallId, {
+ const call = {
toolCallId: start.toolCallId,
name: start.toolCallName,
args: ''
- })
+ }
+ toolCalls.set(start.toolCallId, call)
+ const host =
+ currentMessage ??
+ messages.at(-1) ??
+ messageFor(`assistant-${messages.length + 1}`)
+ host.toolCalls.push(call)
+ toolCallHosts.set(start.toolCallId, host)
break
}
@@
- if (toolCalls.size > 0) {
- let host = messages.at(-1)
- if (!host) {
- host = { id: 'assistant', role: 'assistant', text: '', toolCalls: [] }
- messages.push(host)
- }
- host.toolCalls.push(...toolCalls.values())
- }
-
return { status, error, messages, generativeUI }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/streaming/agui/client.ts` around lines 192 - 254, The tool-call buffering
in the AG-UI client is attaching all calls to messages.at(-1) at the end, which
can misattribute earlier calls to the wrong assistant message. Update the event
reducer in client.ts to track ownership at TOOL_CALL_START by recording the
current assistant message (or a message identifier/host reference) alongside
each toolCallId, then use that stored association when appending calls instead
of always using the last message. Keep the change localized to the toolCalls
assembly logic and the final attachment block so each call is pushed onto the
message that emitted it.
| const stream = new ReadableStream<Uint8Array>({ | ||
| async start(controller) { | ||
| const send = (event: BaseEvent) => | ||
| controller.enqueue(textEncoder.encode(encoder.encodeSSE(event))) | ||
|
|
||
| send({ type: EventType.RUN_STARTED, threadId, runId } as BaseEvent) | ||
|
|
||
| try { | ||
| const fullStream = await startRun(options.abortSignal) | ||
| const state = createAguiMapState() | ||
| let terminalError = false | ||
| outer: for await (const part of fullStream) { | ||
| for (const event of mapFullStreamPart(part, state)) { | ||
| send(event) | ||
| // An `error` fullStream part maps to RUN_ERROR, which terminates | ||
| // the run: stop consuming and do not emit RUN_FINISHED. | ||
| if (event.type === EventType.RUN_ERROR) { | ||
| terminalError = true | ||
| break outer | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!terminalError) { | ||
| send({ type: EventType.RUN_FINISHED, threadId, runId } as BaseEvent) | ||
| } | ||
| } catch (error) { | ||
| send({ | ||
| type: EventType.RUN_ERROR, | ||
| message: getErrorMessage(error) | ||
| } as BaseEvent) | ||
| } finally { | ||
| controller.close() | ||
| } | ||
| } | ||
| }) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Abort the upstream run when the SSE client disconnects.
This ReadableStream never implements cancel(), so a dropped SSE connection does not abort startRun. The model stream can keep running after the client is gone, and later writes can hit a canceled controller.
Proposed fix
export function aguiSseResponse(
startRun: FullStreamFactory,
ids: { threadId: string; runId: string },
options: { abortSignal?: AbortSignal } = {}
): Response {
const { threadId, runId } = ids
const encoder = new EventEncoder()
const textEncoder = new TextEncoder()
+ const upstreamAbortController = new AbortController()
+
+ const forwardAbort = () => {
+ upstreamAbortController.abort(options.abortSignal?.reason)
+ }
+
+ if (options.abortSignal) {
+ if (options.abortSignal.aborted) {
+ forwardAbort()
+ } else {
+ options.abortSignal.addEventListener('abort', forwardAbort, {
+ once: true
+ })
+ }
+ }
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: BaseEvent) =>
controller.enqueue(textEncoder.encode(encoder.encodeSSE(event)))
send({ type: EventType.RUN_STARTED, threadId, runId } as BaseEvent)
try {
- const fullStream = await startRun(options.abortSignal)
+ const fullStream = await startRun(upstreamAbortController.signal)
const state = createAguiMapState()
let terminalError = false
outer: for await (const part of fullStream) {
for (const event of mapFullStreamPart(part, state)) {
send(event)
if (event.type === EventType.RUN_ERROR) {
terminalError = true
break outer
}
}
}
if (!terminalError) {
send({ type: EventType.RUN_FINISHED, threadId, runId } as BaseEvent)
}
} catch (error) {
send({
type: EventType.RUN_ERROR,
message: getErrorMessage(error)
} as BaseEvent)
} finally {
+ options.abortSignal?.removeEventListener('abort', forwardAbort)
controller.close()
}
+ },
+ cancel() {
+ upstreamAbortController.abort()
}
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const stream = new ReadableStream<Uint8Array>({ | |
| async start(controller) { | |
| const send = (event: BaseEvent) => | |
| controller.enqueue(textEncoder.encode(encoder.encodeSSE(event))) | |
| send({ type: EventType.RUN_STARTED, threadId, runId } as BaseEvent) | |
| try { | |
| const fullStream = await startRun(options.abortSignal) | |
| const state = createAguiMapState() | |
| let terminalError = false | |
| outer: for await (const part of fullStream) { | |
| for (const event of mapFullStreamPart(part, state)) { | |
| send(event) | |
| // An `error` fullStream part maps to RUN_ERROR, which terminates | |
| // the run: stop consuming and do not emit RUN_FINISHED. | |
| if (event.type === EventType.RUN_ERROR) { | |
| terminalError = true | |
| break outer | |
| } | |
| } | |
| } | |
| if (!terminalError) { | |
| send({ type: EventType.RUN_FINISHED, threadId, runId } as BaseEvent) | |
| } | |
| } catch (error) { | |
| send({ | |
| type: EventType.RUN_ERROR, | |
| message: getErrorMessage(error) | |
| } as BaseEvent) | |
| } finally { | |
| controller.close() | |
| } | |
| } | |
| }) | |
| const upstreamAbortController = new AbortController() | |
| const forwardAbort = () => { | |
| upstreamAbortController.abort(options.abortSignal?.reason) | |
| } | |
| if (options.abortSignal) { | |
| if (options.abortSignal.aborted) { | |
| forwardAbort() | |
| } else { | |
| options.abortSignal.addEventListener('abort', forwardAbort, { | |
| once: true | |
| }) | |
| } | |
| } | |
| const stream = new ReadableStream<Uint8Array>({ | |
| async start(controller) { | |
| const send = (event: BaseEvent) => | |
| controller.enqueue(textEncoder.encode(encoder.encodeSSE(event))) | |
| send({ type: EventType.RUN_STARTED, threadId, runId } as BaseEvent) | |
| try { | |
| const fullStream = await startRun(upstreamAbortController.signal) | |
| const state = createAguiMapState() | |
| let terminalError = false | |
| outer: for await (const part of fullStream) { | |
| for (const event of mapFullStreamPart(part, state)) { | |
| send(event) | |
| // An `error` fullStream part maps to RUN_ERROR, which terminates | |
| // the run: stop consuming and do not emit RUN_FINISHED. | |
| if (event.type === EventType.RUN_ERROR) { | |
| terminalError = true | |
| break outer | |
| } | |
| } | |
| } | |
| if (!terminalError) { | |
| send({ type: EventType.RUN_FINISHED, threadId, runId } as BaseEvent) | |
| } | |
| } catch (error) { | |
| send({ | |
| type: EventType.RUN_ERROR, | |
| message: getErrorMessage(error) | |
| } as BaseEvent) | |
| } finally { | |
| options.abortSignal?.removeEventListener('abort', forwardAbort) | |
| controller.close() | |
| } | |
| }, | |
| cancel() { | |
| upstreamAbortController.abort() | |
| } | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/streaming/agui/sse.ts` around lines 34 - 69, The SSE ReadableStream in
sse.ts does not stop the upstream run when the client disconnects because it
only defines start() and never handles cancel(). Add a cancel(reason) handler on
the ReadableStream to abort the same signal passed into
startRun(options.abortSignal), and make sure the stream cleanup prevents further
send() calls after cancellation. Use the existing startRun and controller logic
in the stream setup to wire the disconnect through so the model stream stops as
soon as the SSE connection is dropped.
The graph-freshness CI guard (scripts/check-graph-freshness.py) failed because the AG-UI work added 14 code files + 2 dependencies without updating the committed knowledge graph. Append the 60 new AST symbols (lib/streaming/agui/*, components/agui/*, app/agui-demo, app/api/agui, and the @ag-ui/core / @ag-ui/encoder package.json deps) to graphify-out/graph.json so its AST symbol set matches the code. Purely additive; existing nodes, links, and the curated report/labels are unchanged. Verified the freshness check passes in a simulated bare checkout (the only local diffs were gitignored generated files — next-env.d.ts and .husky/_/husky.sh — which CI's checkout does not contain). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 911f95999c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // AG-UI assistant content may be string | InputContent[]; only forward | ||
| // plain-text turns. system/user content is already string-typed. | ||
| const content = message.content | ||
| if (typeof content !== 'string' || content.length === 0) continue |
There was a problem hiding this comment.
Preserve text parts from multimodal AG-UI messages
When an AG-UI client sends a valid user message whose content is an InputContent[] — for example text represented as [{ type: 'text', text: '...' }] alongside image/file parts — this guard drops the entire turn before calling the chat agent. The endpoint then runs with an empty or truncated history even though the request is protocol-valid, so compatible AG-UI frontends lose the user's prompt; concatenate text parts and map supported media instead of skipping all non-string content.
Useful? React with 👍 / 👎.
| // TOOL_CALL_END, TEXT_MESSAGE_END, lifecycle/step events carry no state | ||
| // we need to reduce here. | ||
| default: | ||
| break |
There was a problem hiding this comment.
Handle AG-UI chunk events in the reducer
When an external AG-UI agent emits TEXT_MESSAGE_CHUNK or TOOL_CALL_CHUNK events, those standard @ag-ui/core events fall through this default branch because this hand-rolled consumer only handles the expanded start/content/end lifecycle. That yields a finished run with missing assistant text or tool calls for agents that use the chunk convenience events; expand these events here or run the stream through the AG-UI client normalizer before reducing it.
Useful? React with 👍 / 👎.
…stops blocking PRs Make the CI 'Graph Freshness' guard self-maintaining instead of a recurring manual chore, and fix two reproducibility traps that made it fail even after a naive refresh. - scripts/refresh-graph.py: deterministic AST-only refresh that mirrors the guard's own detect()+extract() and reconciles graph.json's AST nodes on (source_file, label) — the exact key the guard compares (NOT node id, which is path-derived and environment-dependent). Passes the guard by construction; preserves semantic nodes, links, and the curated report. Deliberately not bare Re-extracting code files in . (no LLM needed)... AST extraction: 100/847 uncached files (11%) [4 workers] AST extraction: 200/847 uncached files (23%) [4 workers] AST extraction: 300/847 uncached files (35%) [4 workers] AST extraction: 400/847 uncached files (47%) [4 workers] AST extraction: 500/847 uncached files (59%) [4 workers] AST extraction: 600/847 uncached files (70%) [4 workers] AST extraction: 700/847 uncached files (82%) [4 workers] AST extraction: 800/847 uncached files (94%) [4 workers] AST extraction: 987/987 files (100%) [4 workers] [graphify] backed up curated graph (4 files) -> 2026-06-29/ [graphify watch] Skipped graph.html: Graph has 5312 nodes - too large for HTML viz (limit: 5000). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, or reduce input size. [graphify watch] Rebuilt: 5312 nodes, 10891 edges, 332 communities [graphify watch] graph.json and GRAPH_REPORT.md updated in graphify-out Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant. Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction., which does a full rebuild that re-ASTs docs and diverges from the curated graph. - scripts/refresh-graph.sh: wrapper that resolves a graphify-capable interpreter and skips gracefully (exit 0) when graphify isn't installed, so it never blocks contributors without the Python tooling — CI stays the backstop. - .husky/pre-commit: run the refresh and stage graph.json when code is staged. - .graphifyignore: exclude next-env.d.ts and .husky/_/ (generated files that exist locally after install/build but not in CI's bare checkout — graphing them made the guard environment-dependent). Also exclude the new scripts. - AGENTS.md: point the 'after modifying code' step at scripts/refresh-graph.sh and warn off the divergent Re-extracting code files in . (no LLM needed)... AST extraction: 100/847 uncached files (11%) [4 workers] AST extraction: 200/847 uncached files (23%) [4 workers] AST extraction: 300/847 uncached files (35%) [4 workers] AST extraction: 400/847 uncached files (47%) [4 workers] AST extraction: 500/847 uncached files (59%) [4 workers] AST extraction: 600/847 uncached files (70%) [4 workers] AST extraction: 700/847 uncached files (82%) [4 workers] AST extraction: 800/847 uncached files (94%) [4 workers] AST extraction: 987/987 files (100%) [4 workers] [graphify watch] No code-graph topology changes detected; outputs left untouched. Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant. Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.. Verified end-to-end: adding/removing a code symbol refreshes the graph and the guard passes both ways; the no-graphify path skips cleanly; format:check passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/refresh-graph.py`:
- Around line 104-108: The new-node append loop in refresh-graph.py uses
add_sigs directly, which is a set and produces nondeterministic ordering. Update
the add_sigs handling in the node-building block so the entries are processed in
a stable sorted order before appending AST nodes to g["nodes"], preserving
deterministic graph.json output and minimal diffs across runs.
In `@scripts/refresh-graph.sh`:
- Around line 6-8: The local graph refresh flow in refresh-graph.sh is using an
unpinned graphifyy install, which can diverge from CI. Update the script’s
install/run guidance so it explicitly uses graphifyy version 0.8.38, matching
the freshness check in CI, and make sure both the fallback local tool invocation
and any install instructions reference that same pinned version.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 448e7312-6b43-4d26-b8e1-851669b75b52
📒 Files selected for processing (5)
.graphifyignore.husky/pre-commitAGENTS.mdscripts/refresh-graph.pyscripts/refresh-graph.sh
✅ Files skipped from review due to trivial changes (3)
- .husky/pre-commit
- .graphifyignore
- AGENTS.md
| if add_sigs: | ||
| new_community = ( | ||
| max((n.get("community", 0) for n in g["nodes"]), default=0) + 1 | ||
| ) | ||
| for sig in add_sigs: |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Sort add_sigs before appending new nodes.
add_sigs is a set, so Line 108 appends new AST nodes in hash order. That makes graphify-out/graph.json ordering vary across runs and undercuts the “diff stays minimal” goal in Lines 124-125.
Proposed fix
- for sig in add_sigs:
+ for sig in sorted(add_sigs):
n = fresh_by_sig[sig]
g["nodes"].append(📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if add_sigs: | |
| new_community = ( | |
| max((n.get("community", 0) for n in g["nodes"]), default=0) + 1 | |
| ) | |
| for sig in add_sigs: | |
| if add_sigs: | |
| new_community = ( | |
| max((n.get("community", 0) for n in g["nodes"]), default=0) + 1 | |
| ) | |
| for sig in sorted(add_sigs): |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/refresh-graph.py` around lines 104 - 108, The new-node append loop in
refresh-graph.py uses add_sigs directly, which is a set and produces
nondeterministic ordering. Update the add_sigs handling in the node-building
block so the entries are processed in a stable sorted order before appending AST
nodes to g["nodes"], preserving deterministic graph.json output and minimal
diffs across runs.
| # Skips gracefully (exit 0) when graphify isn't installed, so it never blocks a | ||
| # commit/push for contributors without the Python tooling — CI remains the | ||
| # backstop. Install graphify with: uv tool install graphifyy |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
rg -n "graphifyy|graphify" .github/workflows/ci.yml scripts/refresh-graph.sh
python - <<'PY'
from pathlib import Path
import re
ci = Path(".github/workflows/ci.yml").read_text()
sh = Path("scripts/refresh-graph.sh").read_text()
ci_pin = re.search(r"graphifyy==([0-9.]+)", ci)
print("CI graphifyy pin:", ci_pin.group(1) if ci_pin else "<missing>")
print("refresh-graph.sh uses exact pin:", "graphifyy==" in sh)
PYRepository: NickB03/polymorph
Length of output: 1678
Pin graphifyy to 0.8.38. scripts/refresh-graph.sh can use any local graphifyy install, or uv tool run --from graphifyy, while CI uses graphifyy==0.8.38. That mismatch can make a local refresh pass and still fail the freshness check in CI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/refresh-graph.sh` around lines 6 - 8, The local graph refresh flow in
refresh-graph.sh is using an unpinned graphifyy install, which can diverge from
CI. Update the script’s install/run guidance so it explicitly uses graphifyy
version 0.8.38, matching the freshness check in CI, and make sure both the
fallback local tool invocation and any install instructions reference that same
pinned version.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90bed2f657
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| { type: EventType.TEXT_MESSAGE_END, messageId: part.id } as BaseEvent | ||
| ] | ||
|
|
||
| case 'tool-input-start': |
There was a problem hiding this comment.
Handle AI SDK tool-call streaming parts
When tool-call input is streamed by the AI SDK, the fullStream parts are emitted as tool-call-streaming-start / tool-call-delta with toolCallId and argsTextDelta (checked the AI SDK v6 streamText fullStream reference), so these tool-input-* branches never run for real streamed tool arguments. In that case AG-UI clients get no live TOOL_CALL_START/TOOL_CALL_ARGS updates and only see the expanded final tool-call after input parsing completes, which defeats the endpoint's advertised streaming tool-call lifecycle.
Useful? React with 👍 / 👎.
Cross-repo plan (Polymorph + nickb.net) to replace the portfolio's basic Q&A chat with the live Polymorph agent over AG-UI, so the site itself demos Polymorph. Grounded in this repo: the gating prerequisite is a secured public /api/agui (bearer auth, Upstash rate-limiting + a global daily budget, a bounded/cheap public agent, AGUI_DEMO as the $0 fallback). Recommends a server-side proxy on nickb.net (keeps the key server-side, no CORS) and a 3-phase rollout (secure+embed -> demo generative UI -> agent drives the site). No code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a2dce77ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| intent | ||
| }) | ||
|
|
||
| const modelMessages = aguiMessagesToModelMessages(input.messages) |
There was a problem hiding this comment.
Prune AG-UI history before streaming
When an AG-UI client sends the full thread history on each stateless run, this passes every converted message directly into agent.stream; unlike the existing chat and guest streaming paths, it never calls pruneMessages or maybeTruncateMessages, so a normal long conversation can exceed the selected model's context window and end as RUN_ERROR instead of producing a response. Apply the same pruning/truncation step before starting the agent stream.
Useful? React with 👍 / 👎.
| * environments where that cost/abuse surface is acceptable (e.g. behind a | ||
| * gateway or for a controlled demo). | ||
| */ | ||
| export async function POST(req: Request) { |
There was a problem hiding this comment.
Add CORS handling for external AG-UI clients
If the AG-UI frontend is served from a different origin, which this endpoint advertises by targeting external frontends such as CopilotKit/Dojo, the browser will preflight the JSON POST and require Access-Control-Allow-* headers. This route only implements POST and returns no CORS headers, and there is no shared CORS handling elsewhere in the app, so those clients are blocked before the SSE stream can start; add an OPTIONS handler and a configurable allowed-origin policy for this endpoint.
Useful? React with 👍 / 👎.
Add research doc mapping the AG-UI (Agent-User Interaction) protocol and the
MCP Apps (SEP-1865) / mcp-ui frameworks against Polymorph's existing
generative-UI, streaming, and tool-UI architecture. Identifies three
prioritized showcase initiatives (AG-UI server endpoint, MCP Apps host,
MCP Apps server) with concrete codebase integration points and a sequencing
recommendation.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_011w4vB2hXtTZ9hViYxqeTE6
Summary by CodeRabbit