Skip to content

Commit 6ec39d8

Browse files
authored
feat(react shell): migrate React shell sample to A2UI v0.9 (#1262)
1 parent e37809a commit 6ec39d8

10 files changed

Lines changed: 612 additions & 333 deletions

File tree

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ samples/client/angular/projects/mcp_calculator/public/mcp_apps_inner_iframe/
2626
samples/agent/mcp/a2ui-in-mcpapps/server/apps/dist
2727
samples/agent/mcp/a2ui-in-mcpapps/server/apps/public
2828

29+
# Vite cache
30+
.vite/
31+
32+
# Git worktrees
33+
worktrees/
34+
35+
# TypeScript incremental build info
36+
*.tsbuildinfo
37+
2938
# Local-only scratch files.
3039
.local/
3140

samples/agent/adk/restaurant_finder/agent_executor.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ async def execute(
7070
)
7171
for i, part in enumerate(context.message.parts):
7272
if isinstance(part.root, DataPart):
73-
if "userAction" in part.root.data:
74-
logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.")
73+
if part.root.data.get("version") == "v0.9" and "action" in part.root.data:
74+
logger.info(f" Part {i}: Found a2ui v0.9 action payload.")
75+
ui_event_part = part.root.data["action"]
76+
elif "userAction" in part.root.data:
77+
logger.info(f" Part {i}: Found a2ui v0.8 UI ClientEvent payload.")
7578
ui_event_part = part.root.data["userAction"]
7679
else:
7780
logger.info(f" Part {i}: DataPart (data: {part.root.data})")
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { IncomingMessage, ServerResponse } from "http";
18+
import { Plugin, ViteDevServer } from "vite";
19+
import { A2AClient } from "@a2a-js/sdk/client";
20+
import {
21+
MessageSendParams,
22+
Part,
23+
SendMessageSuccessResponse,
24+
Task,
25+
} from "@a2a-js/sdk";
26+
import * as crypto from "crypto";
27+
28+
const A2UI_MIME_TYPE = "application/json+a2ui";
29+
const enableStreaming = process.env["ENABLE_STREAMING"] !== "false";
30+
31+
const fetchWithCustomHeader: typeof fetch = async (url, init) => {
32+
const headers = new Headers(init?.headers);
33+
headers.set("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9");
34+
35+
const newInit = { ...init, headers };
36+
return fetch(url, newInit);
37+
};
38+
39+
const isJson = (str: string) => {
40+
try {
41+
const parsed = JSON.parse(str);
42+
return (
43+
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
44+
);
45+
} catch (err) {
46+
return false;
47+
}
48+
};
49+
50+
let client: A2AClient | null = null;
51+
const createOrGetClient = async () => {
52+
if (!client) {
53+
client = await A2AClient.fromCardUrl(
54+
"http://localhost:10002/.well-known/agent-card.json",
55+
{ fetchImpl: fetchWithCustomHeader }
56+
);
57+
}
58+
59+
return client;
60+
};
61+
62+
export const plugin = (): Plugin => {
63+
return {
64+
name: "a2a-handler",
65+
configureServer(server: ViteDevServer) {
66+
server.middlewares.use(
67+
"/a2a",
68+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
69+
if (req.method === "POST") {
70+
let originalBody = "";
71+
// Cap the in-memory request body so a misbehaving shell can't
72+
// exhaust the dev server's heap.
73+
const MAX_PAYLOAD_SIZE = 1024 * 1024;
74+
75+
req.on("data", (chunk) => {
76+
originalBody += chunk.toString();
77+
if (originalBody.length > MAX_PAYLOAD_SIZE) {
78+
res.statusCode = 413;
79+
res.setHeader("Content-Type", "application/json");
80+
res.end(JSON.stringify({ error: "Payload too large" }));
81+
req.destroy();
82+
}
83+
});
84+
85+
req.on("end", async () => {
86+
if (res.writableEnded) return; // Aborted by size limit.
87+
88+
let sendParams: MessageSendParams;
89+
90+
if (isJson(originalBody)) {
91+
console.log(
92+
"[a2a-middleware] Received JSON UI event:",
93+
originalBody
94+
);
95+
96+
const clientEvent = JSON.parse(originalBody) as Record<string, unknown>;
97+
98+
sendParams = {
99+
message: {
100+
messageId: crypto.randomUUID(),
101+
role: "user",
102+
parts: [
103+
{
104+
kind: "data",
105+
data: clientEvent,
106+
mimeType: A2UI_MIME_TYPE,
107+
} as Part,
108+
],
109+
kind: "message",
110+
},
111+
};
112+
} else {
113+
console.log(
114+
"[a2a-middleware] Received text query:",
115+
originalBody
116+
);
117+
sendParams = {
118+
message: {
119+
messageId: crypto.randomUUID(),
120+
role: "user",
121+
parts: [
122+
{
123+
kind: "text",
124+
text: originalBody,
125+
},
126+
],
127+
kind: "message",
128+
},
129+
};
130+
}
131+
132+
const client = await createOrGetClient();
133+
134+
try {
135+
if (enableStreaming) {
136+
const stream = await client.sendMessageStream(sendParams);
137+
res.statusCode = 200;
138+
139+
res.setHeader("Content-Type", "text/event-stream");
140+
res.setHeader("Cache-Control", "no-cache");
141+
res.setHeader("Connection", "keep-alive");
142+
143+
for await (const chunk of stream) {
144+
// Client disconnected; stop pulling from the agent.
145+
if (res.destroyed) break;
146+
// A2AClient unpacks the JSON-RPC, so chunk is an A2AStreamEventData.
147+
if (chunk.kind === "status-update" && chunk.status.message?.parts) {
148+
res.write(`data: ${JSON.stringify(chunk.status.message.parts)}\n\n`);
149+
} else if (chunk.kind === "message" && chunk.parts) {
150+
res.write(`data: ${JSON.stringify(chunk.parts)}\n\n`);
151+
}
152+
}
153+
res.end();
154+
} else {
155+
const response = await client.sendMessage(sendParams);
156+
res.setHeader("Cache-Control", "no-store");
157+
if ("error" in response) {
158+
res.statusCode = 500;
159+
res.setHeader("Content-Type", "application/json");
160+
res.end(JSON.stringify({ error: response.error.message }));
161+
} else {
162+
const result = (response as SendMessageSuccessResponse).result as Task;
163+
res.statusCode = 200;
164+
res.setHeader("Content-Type", "application/json");
165+
res.end(JSON.stringify(result.kind === "task" ? result.status.message?.parts || [] : []));
166+
}
167+
}
168+
} catch (e: unknown) {
169+
console.error("Error during streaming:", e);
170+
const errorMessage = e instanceof Error ? e.message : String(e);
171+
if (!res.headersSent) {
172+
res.statusCode = 500;
173+
res.setHeader("Content-Type", "application/json");
174+
res.end(JSON.stringify({ error: errorMessage }));
175+
} else {
176+
res.write(`data: ${JSON.stringify([{ kind: "error", text: errorMessage }])}\n\n`);
177+
res.end();
178+
}
179+
}
180+
});
181+
182+
return;
183+
} else {
184+
next();
185+
}
186+
}
187+
);
188+
},
189+
};
190+
};

samples/client/react/shell/package-lock.json

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

samples/client/react/shell/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
},
1212
"dependencies": {
1313
"@a2a-js/sdk": "^0.3.4",
14+
"@a2ui/markdown-it": "file:../../../../renderers/markdown/markdown-it",
1415
"@a2ui/react": "file:../../../../renderers/react",
16+
"@a2ui/web_core": "file:../../../../renderers/web_core",
1517
"react": "^18.3.0",
1618
"react-dom": "^18.3.0"
1719
},
1820
"devDependencies": {
21+
"@types/node": "^20.19.39",
1922
"@types/react": "^18.3.0",
2023
"@types/react-dom": "^18.3.0",
2124
"@vitejs/plugin-react": "^4.3.0",

0 commit comments

Comments
 (0)