feat(examples): add AG-UI polyglot chat backends#963
Conversation
…ends Introduce examples/ag-ui, a React SPA using @tanstack/ai-react-ui against hand-rolled AG-UI SSE servers in Go and Rust that stream OpenAI and Anthropic chat completions. Co-authored-by: Cursor <cursoragent@cursor.com>
Add PHP and Zig servers with runtime detection so the example can expose every available backend and guide setup for missing toolchains. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Expand the polyglot example with dependency-light servers and toolchain-aware local startup so the same AG-UI client can demonstrate both runtimes. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep generated interpreter caches out of the AG-UI example sources. Co-authored-by: Cursor <cursoragent@cursor.com>
🚀 Changeset Version PreviewNo changeset entries found. Merging this PR will not cause a version bump for any packages. |
📝 WalkthroughWalkthroughAdds an AG-UI Polyglot Echo example with a React/Vite chat client, runtime-aware development tooling, and Go, Rust, PHP, Zig, Bash, and Python SSE backends supporting OpenAI and Anthropic streaming. ChangesAG-UI Polyglot Echo
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant App
participant ViteProxy
participant AGUIServer
participant LLMProvider
User->>App: select backend and provider
App->>ViteProxy: POST /api/{backend}
ViteProxy->>AGUIServer: rewrite request to /
AGUIServer->>LLMProvider: stream completion
LLMProvider-->>AGUIServer: text deltas
AGUIServer-->>App: AG-UI SSE events
App-->>User: render streamed response
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" 🔧 ESLint
examples/ag-ui/vite.config.tsParsing error: "parserOptions.project" has been provided for 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 |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
|
View your CI Pipeline Execution ↗ for commit 348caf2
☁️ Nx Cloud last updated this comment at |
Apply the repository Markdown table formatting so the autofix check remains clean. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
examples/ag-ui/servers/go/openai.go (1)
73-73: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
bufio.Scanneruses the default 64 KB line limit.
bufio.NewScannercaps tokens atbufio.MaxScanTokenSize(64 KB). An SSEdata:line longer than that makesscanner.Scan()stop andscanner.Err()returnbufio.ErrTooLong, silently truncating the stream. Consider enlarging the buffer or switching tobufio.Reader.ReadString('\n').♻️ Raise the scanner buffer
scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() {🤖 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 `@examples/ag-ui/servers/go/openai.go` at line 73, Increase the buffer capacity for the bufio.Scanner created in the SSE response handling flow, using Scanner.Buffer before scanning so data lines larger than the default 64 KB are accepted. Preserve the existing scanning and error-handling behavior while selecting a sufficiently larger maximum token size.examples/ag-ui/servers/rust/src/agui.rs (1)
65-74: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winUnknown providers are silently coerced to OpenAI (diverges from Python/Bash). Both the Rust and Zig backends treat any provider that isn't
anthropicasopenai, whereas the Python (provider_from_input) and Bash backends in this same example reject unsupported providers with an error. For a demo whose stated goal is a shared cross-language contract, consider aligning on erroring for unrecognized providers.
examples/ag-ui/servers/rust/src/agui.rs#L65-L74: replace the catch-all_arm with an explicit"openai"match and error/fallback handling for other values.examples/ag-ui/servers/zig/src/agui.zig#L54-L64: return an error (or otherwise signalRUN_ERROR) for providers that are neitheropenainoranthropicinstead of defaulting to OpenAI.🤖 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 `@examples/ag-ui/servers/rust/src/agui.rs` around lines 65 - 74, Align provider validation with the shared contract: in examples/ag-ui/servers/rust/src/agui.rs lines 65-74, update the provider match to handle "openai" explicitly and reject all other values instead of defaulting to OpenAI; in examples/ag-ui/servers/zig/src/agui.zig lines 54-64, return an error or signal RUN_ERROR for providers other than "openai" and "anthropic".
🤖 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 `@examples/ag-ui/servers/go/agui.go`:
- Around line 89-102: Replace wildcard CORS with an allow-list for the known
Vite development origin. In examples/ag-ui/servers/go/agui.go lines 89-102,
update beginSSE to echo the origin only when it matches http://localhost:5173;
apply the same restriction in examples/ag-ui/servers/php/lib/Agui.php lines
107-131 for begin_sse() and cors_preflight(), with no wildcard responses.
In `@examples/ag-ui/servers/php/lib/Providers.php`:
- Around line 131-190: Update the curl options in stream_provider_sse to add a
finite CURLOPT_CONNECTTIMEOUT and CURLOPT_LOW_SPEED_LIMIT with
CURLOPT_LOW_SPEED_TIME, preventing handshake or stalled-stream hangs while
preserving long-running streams; do not add a blanket CURLOPT_TIMEOUT.
In `@examples/ag-ui/servers/rust/src/messages.rs`:
- Around line 59-77: Update the content parsing function around the array join
and final content.to_string() fallback so arrays without text items return an
empty string instead of raw JSON serialization. Preserve the existing joined
text behavior for arrays containing text parts, and retain raw serialization
only for non-array content.
In `@examples/ag-ui/servers/rust/src/providers.rs`:
- Around line 55-57: Update both streaming loops at
examples/ag-ui/servers/rust/src/providers.rs:55-57 and 138-140 to accumulate raw
byte chunks, split the byte buffer on newline boundaries, and decode only
complete lines. Remove per-chunk String::from_utf8_lossy usage while preserving
the existing line-processing behavior; handle any remaining unterminated bytes
after the stream ends as appropriate.
In `@examples/ag-ui/src/App.tsx`:
- Line 32: Replace the T[] array syntax with Array<T> in both ServerInfo
declarations: update servers in examples/ag-ui/src/App.tsx lines 32-32 and the
useState type in lines 119-119 to use Array<ServerInfo>, preserving the existing
behavior.
- Around line 160-162: Update the active server selection expression near
servers.find to use servers.at(0) instead of servers[0], preserving the trailing
default object fallback for empty server arrays and eliminating the
unnecessary-condition lint error.
- Around line 4-9: Reorder the imports in App.tsx so the value import from
`@tanstack/ai-react-ui` appears before the type import from `@tanstack/ai-react`,
satisfying the import/order rule without changing the imported symbols.
---
Nitpick comments:
In `@examples/ag-ui/servers/go/openai.go`:
- Line 73: Increase the buffer capacity for the bufio.Scanner created in the SSE
response handling flow, using Scanner.Buffer before scanning so data lines
larger than the default 64 KB are accepted. Preserve the existing scanning and
error-handling behavior while selecting a sufficiently larger maximum token
size.
In `@examples/ag-ui/servers/rust/src/agui.rs`:
- Around line 65-74: Align provider validation with the shared contract: in
examples/ag-ui/servers/rust/src/agui.rs lines 65-74, update the provider match
to handle "openai" explicitly and reject all other values instead of defaulting
to OpenAI; in examples/ag-ui/servers/zig/src/agui.zig lines 54-64, return an
error or signal RUN_ERROR for providers other than "openai" and "anthropic".
🪄 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: dd0af346-a7ee-41af-94aa-8d58acfa0388
⛔ Files ignored due to path filters (4)
examples/ag-ui/bash-server.pngis excluded by!**/*.pngexamples/ag-ui/python-server.pngis excluded by!**/*.pngexamples/ag-ui/servers/rust/Cargo.lockis excluded by!**/*.lockpnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (44)
examples/README.mdexamples/ag-ui/.env.exampleexamples/ag-ui/.gitignoreexamples/ag-ui/README.mdexamples/ag-ui/index.htmlexamples/ag-ui/package.jsonexamples/ag-ui/public/servers.jsonexamples/ag-ui/scripts/detect-servers.mjsexamples/ag-ui/scripts/dev-all.mjsexamples/ag-ui/scripts/servers.mjsexamples/ag-ui/servers/bash/README.mdexamples/ag-ui/servers/bash/server.shexamples/ag-ui/servers/go/README.mdexamples/ag-ui/servers/go/agui.goexamples/ag-ui/servers/go/anthropic.goexamples/ag-ui/servers/go/go.modexamples/ag-ui/servers/go/llm.goexamples/ag-ui/servers/go/main.goexamples/ag-ui/servers/go/messages.goexamples/ag-ui/servers/go/openai.goexamples/ag-ui/servers/php/README.mdexamples/ag-ui/servers/php/lib/Agui.phpexamples/ag-ui/servers/php/lib/Messages.phpexamples/ag-ui/servers/php/lib/Providers.phpexamples/ag-ui/servers/php/router.phpexamples/ag-ui/servers/python/README.mdexamples/ag-ui/servers/python/server.pyexamples/ag-ui/servers/rust/Cargo.tomlexamples/ag-ui/servers/rust/README.mdexamples/ag-ui/servers/rust/src/agui.rsexamples/ag-ui/servers/rust/src/main.rsexamples/ag-ui/servers/rust/src/messages.rsexamples/ag-ui/servers/rust/src/providers.rsexamples/ag-ui/servers/zig/README.mdexamples/ag-ui/servers/zig/build.zigexamples/ag-ui/servers/zig/src/agui.zigexamples/ag-ui/servers/zig/src/main.zigexamples/ag-ui/servers/zig/src/messages.zigexamples/ag-ui/servers/zig/src/providers.zigexamples/ag-ui/src/App.tsxexamples/ag-ui/src/main.tsxexamples/ag-ui/src/styles.cssexamples/ag-ui/tsconfig.jsonexamples/ag-ui/vite.config.ts
| func beginSSE(w http.ResponseWriter) (*sseStream, bool) { | ||
| flusher, ok := w.(http.Flusher) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "text/event-stream") | ||
| w.Header().Set("Cache-Control", "no-cache") | ||
| w.Header().Set("Connection", "keep-alive") | ||
| w.Header().Set("Access-Control-Allow-Origin", "*") | ||
| w.WriteHeader(http.StatusOK) | ||
|
|
||
| return &sseStream{w: w, flusher: flusher}, true | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Wildcard CORS repeated across the Go and PHP SSE servers. Both backends set Access-Control-Allow-Origin: * on their SSE/preflight responses. Since these bind to 127.0.0.1, any webpage open in the developer's browser could hit them and trigger LLM calls against the configured API keys, reading back the streamed output.
examples/ag-ui/servers/go/agui.go#L89-L102: inbeginSSE, replace the wildcard with a check against the known Vite dev origin (e.g.http://localhost:5173) before echoing it back.examples/ag-ui/servers/php/lib/Agui.php#L107-L131: inbegin_sse()andcors_preflight(), apply the same origin allow-list instead of*.
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 97-97: Setting the 'Access-Control-Allow-Origin' response header to the wildcard '' disables the same-origin policy and lets any website read the response. This is especially dangerous when combined with 'Access-Control-Allow-Credentials', as it can expose authenticated data to attacker-controlled origins. Instead of '', reflect a request origin only after validating it against an allow-list of trusted origins.
Context: w.Header().Set("Access-Control-Allow-Origin", "*")
Note: [CWE-942] Permissive Cross-domain Policy with Untrusted Domains.
(cors-wildcard-origin-header-go)
📍 Affects 2 files
examples/ag-ui/servers/go/agui.go#L89-L102(this comment)examples/ag-ui/servers/php/lib/Agui.php#L107-L131
🤖 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 `@examples/ag-ui/servers/go/agui.go` around lines 89 - 102, Replace wildcard
CORS with an allow-list for the known Vite development origin. In
examples/ag-ui/servers/go/agui.go lines 89-102, update beginSSE to echo the
origin only when it matches http://localhost:5173; apply the same restriction in
examples/ag-ui/servers/php/lib/Agui.php lines 107-131 for begin_sse() and
cors_preflight(), with no wildcard responses.
Source: Linters/SAST tools
| function stream_provider_sse( | ||
| string $url, | ||
| array $headers, | ||
| string $payload, | ||
| callable $handleEvent | ||
| ): void { | ||
| $buffer = ''; | ||
|
|
||
| $ch = curl_init($url); | ||
| if ($ch === false) { | ||
| throw new RuntimeException('failed to initialize curl'); | ||
| } | ||
|
|
||
| curl_setopt_array($ch, [ | ||
| CURLOPT_POST => true, | ||
| CURLOPT_HTTPHEADER => $headers, | ||
| CURLOPT_POSTFIELDS => $payload, | ||
| CURLOPT_RETURNTRANSFER => false, | ||
| CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int { | ||
| $buffer .= $chunk; | ||
|
|
||
| while (($lineEnd = strpos($buffer, "\n")) !== false) { | ||
| $line = rtrim(substr($buffer, 0, $lineEnd), "\r"); | ||
| $buffer = substr($buffer, $lineEnd + 1); | ||
|
|
||
| if (!str_starts_with($line, 'data: ')) { | ||
| continue; | ||
| } | ||
|
|
||
| $data = substr($line, 6); | ||
| if ($data === '[DONE]') { | ||
| return strlen($chunk); | ||
| } | ||
|
|
||
| $parsed = json_decode($data, true); | ||
| if (!is_array($parsed)) { | ||
| continue; | ||
| } | ||
|
|
||
| $handleEvent($parsed); | ||
| } | ||
|
|
||
| return strlen($chunk); | ||
| }, | ||
| ]); | ||
|
|
||
| $ok = curl_exec($ch); | ||
| if ($ok === false) { | ||
| $error = curl_error($ch); | ||
| curl_close($ch); | ||
| throw new RuntimeException($error !== '' ? $error : 'provider request failed'); | ||
| } | ||
|
|
||
| $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | ||
| curl_close($ch); | ||
|
|
||
| if ($status >= 400) { | ||
| throw new RuntimeException('provider request failed (' . $status . ')'); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
No connection/stall timeout on the outbound provider request.
curl_setopt_array sets no CURLOPT_CONNECTTIMEOUT or CURLOPT_LOW_SPEED_LIMIT/CURLOPT_LOW_SPEED_TIME. If api.openai.com/api.anthropic.com never completes the TCP/TLS handshake or stalls mid-stream, this call can hang indefinitely — and since PHP's built-in dev server typically serves one request at a time, that hang can block the entire demo server, including /health. A blanket CURLOPT_TIMEOUT would be wrong here since it would also cut off long legitimate streams, but a connect timeout plus a low-speed guard won't.
🔧 Proposed fix
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => false,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_LOW_SPEED_LIMIT => 1,
+ CURLOPT_LOW_SPEED_TIME => 30,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int {📝 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 stream_provider_sse( | |
| string $url, | |
| array $headers, | |
| string $payload, | |
| callable $handleEvent | |
| ): void { | |
| $buffer = ''; | |
| $ch = curl_init($url); | |
| if ($ch === false) { | |
| throw new RuntimeException('failed to initialize curl'); | |
| } | |
| curl_setopt_array($ch, [ | |
| CURLOPT_POST => true, | |
| CURLOPT_HTTPHEADER => $headers, | |
| CURLOPT_POSTFIELDS => $payload, | |
| CURLOPT_RETURNTRANSFER => false, | |
| CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int { | |
| $buffer .= $chunk; | |
| while (($lineEnd = strpos($buffer, "\n")) !== false) { | |
| $line = rtrim(substr($buffer, 0, $lineEnd), "\r"); | |
| $buffer = substr($buffer, $lineEnd + 1); | |
| if (!str_starts_with($line, 'data: ')) { | |
| continue; | |
| } | |
| $data = substr($line, 6); | |
| if ($data === '[DONE]') { | |
| return strlen($chunk); | |
| } | |
| $parsed = json_decode($data, true); | |
| if (!is_array($parsed)) { | |
| continue; | |
| } | |
| $handleEvent($parsed); | |
| } | |
| return strlen($chunk); | |
| }, | |
| ]); | |
| $ok = curl_exec($ch); | |
| if ($ok === false) { | |
| $error = curl_error($ch); | |
| curl_close($ch); | |
| throw new RuntimeException($error !== '' ? $error : 'provider request failed'); | |
| } | |
| $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| curl_close($ch); | |
| if ($status >= 400) { | |
| throw new RuntimeException('provider request failed (' . $status . ')'); | |
| } | |
| } | |
| function stream_provider_sse( | |
| string $url, | |
| array $headers, | |
| string $payload, | |
| callable $handleEvent | |
| ): void { | |
| $buffer = ''; | |
| $ch = curl_init($url); | |
| if ($ch === false) { | |
| throw new RuntimeException('failed to initialize curl'); | |
| } | |
| curl_setopt_array($ch, [ | |
| CURLOPT_POST => true, | |
| CURLOPT_HTTPHEADER => $headers, | |
| CURLOPT_POSTFIELDS => $payload, | |
| CURLOPT_RETURNTRANSFER => false, | |
| CURLOPT_CONNECTTIMEOUT => 10, | |
| CURLOPT_LOW_SPEED_LIMIT => 1, | |
| CURLOPT_LOW_SPEED_TIME => 30, | |
| CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int { | |
| $buffer .= $chunk; | |
| while (($lineEnd = strpos($buffer, "\n")) !== false) { | |
| $line = rtrim(substr($buffer, 0, $lineEnd), "\r"); | |
| $buffer = substr($buffer, $lineEnd + 1); | |
| if (!str_starts_with($line, 'data: ')) { | |
| continue; | |
| } | |
| $data = substr($line, 6); | |
| if ($data === '[DONE]') { | |
| return strlen($chunk); | |
| } | |
| $parsed = json_decode($data, true); | |
| if (!is_array($parsed)) { | |
| continue; | |
| } | |
| $handleEvent($parsed); | |
| } | |
| return strlen($chunk); | |
| }, | |
| ]); | |
| $ok = curl_exec($ch); | |
| if ($ok === false) { | |
| $error = curl_error($ch); | |
| curl_close($ch); | |
| throw new RuntimeException($error !== '' ? $error : 'provider request failed'); | |
| } | |
| $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| curl_close($ch); | |
| if ($status >= 400) { | |
| throw new RuntimeException('provider request failed (' . $status . ')'); | |
| } | |
| } |
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 149-149: Avoid unused parameters such as '$handle'. (undefined)
(UnusedFormalParameter)
🤖 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 `@examples/ag-ui/servers/php/lib/Providers.php` around lines 131 - 190, Update
the curl options in stream_provider_sse to add a finite CURLOPT_CONNECTTIMEOUT
and CURLOPT_LOW_SPEED_LIMIT with CURLOPT_LOW_SPEED_TIME, preventing handshake or
stalled-stream hangs while preserving long-running streams; do not add a blanket
CURLOPT_TIMEOUT.
| if let Some(items) = content.as_array() { | ||
| let joined = items | ||
| .iter() | ||
| .filter_map(|item| { | ||
| if item.get("type")?.as_str()? == "text" { | ||
| item.get("text")?.as_str().map(str::to_string) | ||
| } else { | ||
| None | ||
| } | ||
| }) | ||
| .collect::<Vec<_>>() | ||
| .join(""); | ||
| if !joined.is_empty() { | ||
| return joined; | ||
| } | ||
| } | ||
|
|
||
| content.to_string() | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Non-text content falls through to raw JSON serialization.
When content is an array with no type=="text" items (e.g. image parts), the join at Line 70 is empty, the guard at Line 71 skips the early return, and control reaches Line 76 content.to_string(), which serializes the entire array as JSON text and injects it into the chat message. The Python/PHP equivalents yield an empty string here so the message is dropped downstream. Consider returning an empty string for the non-text fallback to match the cross-language contract.
🔧 Proposed fix
- content.to_string()
+ String::new()📝 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 let Some(items) = content.as_array() { | |
| let joined = items | |
| .iter() | |
| .filter_map(|item| { | |
| if item.get("type")?.as_str()? == "text" { | |
| item.get("text")?.as_str().map(str::to_string) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect::<Vec<_>>() | |
| .join(""); | |
| if !joined.is_empty() { | |
| return joined; | |
| } | |
| } | |
| content.to_string() | |
| } | |
| if let Some(items) = content.as_array() { | |
| let joined = items | |
| .iter() | |
| .filter_map(|item| { | |
| if item.get("type")?.as_str()? == "text" { | |
| item.get("text")?.as_str().map(str::to_string) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect::<Vec<_>>() | |
| .join(""); | |
| if !joined.is_empty() { | |
| return joined; | |
| } | |
| } | |
| String::new() | |
| } |
🤖 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 `@examples/ag-ui/servers/rust/src/messages.rs` around lines 59 - 77, Update the
content parsing function around the array join and final content.to_string()
fallback so arrays without text items return an empty string instead of raw JSON
serialization. Preserve the existing joined text behavior for arrays containing
text parts, and retain raw serialization only for non-array content.
| while let Some(chunk) = stream.next().await { | ||
| let chunk = chunk.map_err(|error| error.to_string())?; | ||
| buffer.push_str(&String::from_utf8_lossy(&chunk)); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
set -euo pipefail
sed -n '1,240p' examples/ag-ui/servers/rust/src/providers.rsRepository: TanStack/ai
Length of output: 5655
Avoid lossy decoding before line assembly
examples/ag-ui/servers/rust/src/providers.rs#L55-L57examples/ag-ui/servers/rust/src/providers.rs#L138-L140
String::from_utf8_lossy runs on each bytes_stream() chunk, so a multibyte UTF-8 character split across chunks can be replaced with U+FFFD. Buffer raw bytes, split on \n, and decode only complete lines.
📍 Affects 1 file
examples/ag-ui/servers/rust/src/providers.rs#L55-L57(this comment)examples/ag-ui/servers/rust/src/providers.rs#L138-L140
🤖 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 `@examples/ag-ui/servers/rust/src/providers.rs` around lines 55 - 57, Update
both streaming loops at examples/ag-ui/servers/rust/src/providers.rs:55-57 and
138-140 to accumulate raw byte chunks, split the byte buffer on newline
boundaries, and decode only complete lines. Remove per-chunk
String::from_utf8_lossy usage while preserving the existing line-processing
behavior; handle any remaining unterminated bytes after the stream ends as
appropriate.
| import { | ||
| Chat, | ||
| ChatInput, | ||
| ChatMessage, | ||
| ChatMessages, | ||
| } from '@tanstack/ai-react-ui' |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix import ordering to satisfy import/order.
ESLint flags the value import from @tanstack/ai-react-ui as needing to precede the type import from @tanstack/ai-react. This fails lint in CI.
♻️ Reorder imports
import { fetchServerSentEvents } from '`@tanstack/ai-react`'
-import type { UIMessage } from '`@tanstack/ai-react`'
import {
Chat,
ChatInput,
ChatMessage,
ChatMessages,
} from '`@tanstack/ai-react-ui`'
+import type { UIMessage } from '`@tanstack/ai-react`'📝 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.
| import { | |
| Chat, | |
| ChatInput, | |
| ChatMessage, | |
| ChatMessages, | |
| } from '@tanstack/ai-react-ui' | |
| import { fetchServerSentEvents } from '`@tanstack/ai-react`' | |
| import { | |
| Chat, | |
| ChatInput, | |
| ChatMessage, | |
| ChatMessages, | |
| } from '`@tanstack/ai-react-ui`' | |
| import type { UIMessage } from '`@tanstack/ai-react`' |
🧰 Tools
🪛 ESLint
[error] 4-9: @tanstack/ai-react-ui import should occur before type import of @tanstack/ai-react
(import/order)
🤖 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 `@examples/ag-ui/src/App.tsx` around lines 4 - 9, Reorder the imports in
App.tsx so the value import from `@tanstack/ai-react-ui` appears before the type
import from `@tanstack/ai-react`, satisfying the import/order rule without
changing the imported symbols.
Source: Linters/SAST tools
|
|
||
| type ServersManifest = { | ||
| generatedAt: string | ||
| servers: ServerInfo[] |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use Array<ServerInfo> generic syntax. The project's @typescript-eslint/array-type rule forbids the T[] shorthand, so both declarations fail lint.
examples/ag-ui/src/App.tsx#L32-L32: changeservers: ServerInfo[]toservers: Array<ServerInfo>.examples/ag-ui/src/App.tsx#L119-L119: changeuseState<ServerInfo[]>([])touseState<Array<ServerInfo>>([]).
🧰 Tools
🪛 ESLint
[error] 32-32: Array type using 'ServerInfo[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
📍 Affects 1 file
examples/ag-ui/src/App.tsx#L32-L32(this comment)examples/ag-ui/src/App.tsx#L119-L119
🤖 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 `@examples/ag-ui/src/App.tsx` at line 32, Replace the T[] array syntax with
Array<T> in both ServerInfo declarations: update servers in
examples/ag-ui/src/App.tsx lines 32-32 and the useState type in lines 119-119 to
use Array<ServerInfo>, preserving the existing behavior.
Source: Linters/SAST tools
| const active = | ||
| servers.find((item) => item.id === backend) ?? | ||
| servers[0] ?? |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
servers[0] fallback is defeated by the type system.
With the current tsconfig (no noUncheckedIndexedAccess), servers[0] is typed as non-nullable ServerInfo, so TS/ESLint treat the trailing ?? {...} default as dead code (the no-unnecessary-condition error at Lines 161-162), even though servers[0] is undefined at runtime when the array is empty. Use servers.at(0), which is typed ServerInfo | undefined, to keep the runtime fallback reachable and satisfy the linter.
🐛 Preserve the runtime fallback
const active =
servers.find((item) => item.id === backend) ??
- servers[0] ??
+ servers.at(0) ??
({📝 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 active = | |
| servers.find((item) => item.id === backend) ?? | |
| servers[0] ?? | |
| const active = | |
| servers.find((item) => item.id === backend) ?? | |
| servers.at(0) ?? |
🧰 Tools
🪛 ESLint
[error] 161-162: Unnecessary conditional, expected left-hand side of ?? operator to be possibly null or undefined.
(@typescript-eslint/no-unnecessary-condition)
🤖 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 `@examples/ag-ui/src/App.tsx` around lines 160 - 162, Update the active server
selection expression near servers.find to use servers.at(0) instead of
servers[0], preserving the trailing default object fallback for empty server
arrays and eliminating the unnecessary-condition lint error.
Source: Linters/SAST tools
Summary
dev:all, and show setup guidance for unavailable runtimesPython support
The example now includes a Python 3.9+ backend built entirely with the standard library (
ThreadingHTTPServerandurllib.request). It supports both OpenAI and Anthropic streaming and is available throughpnpm dev:pythonorpnpm dev:all.Test plan
pnpm test:prpnpm --filter @tanstack/ai-e2e test:e2epnpm test:docspnpm test:types && pnpm buildinexamples/ag-ui/healthand AG-UI SSE error flow locallyMade with Cursor
Summary by CodeRabbit