Skip to content

Commit 23e0348

Browse files
authored
feat: eagerly create default A2UI renderer when middleware is enabled (CopilotKit#3395)
2 parents df0e214 + 0c90355 commit 23e0348

9 files changed

Lines changed: 241 additions & 5 deletions

File tree

.changeset/odd-planets-deliver.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@copilotkitnext/react": patch
3+
---
4+
5+
feat: eagerly create default A2UI renderer when middleware is enabled

packages/v2/core/src/core/agent-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class AgentRegistry {
2929
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
3030
private _runtimeTransport: CopilotRuntimeTransport = "rest";
3131
private _audioFileTranscriptionEnabled: boolean = false;
32+
private _a2uiEnabled: boolean = false;
3233

3334
constructor(private core: CopilotKitCore) {}
3435

@@ -59,6 +60,10 @@ export class AgentRegistry {
5960
return this._audioFileTranscriptionEnabled;
6061
}
6162

63+
get a2uiEnabled(): boolean {
64+
return this._a2uiEnabled;
65+
}
66+
6267
/**
6368
* Initialize agents from configuration
6469
*/
@@ -206,6 +211,7 @@ export class AgentRegistry {
206211
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
207212
this._runtimeVersion = undefined;
208213
this._audioFileTranscriptionEnabled = false;
214+
this._a2uiEnabled = false;
209215
this.remoteAgents = {};
210216
this._agents = this.localAgents;
211217

@@ -255,6 +261,7 @@ export class AgentRegistry {
255261
this._runtimeVersion = version;
256262
this._audioFileTranscriptionEnabled =
257263
runtimeInfoResponse.audioFileTranscriptionEnabled ?? false;
264+
this._a2uiEnabled = runtimeInfoResponse.a2uiEnabled ?? false;
258265

259266
await this.notifyRuntimeStatusChanged(
260267
CopilotKitCoreRuntimeConnectionStatus.Connected,
@@ -265,6 +272,7 @@ export class AgentRegistry {
265272
CopilotKitCoreRuntimeConnectionStatus.Error;
266273
this._runtimeVersion = undefined;
267274
this._audioFileTranscriptionEnabled = false;
275+
this._a2uiEnabled = false;
268276
this.remoteAgents = {};
269277
this._agents = this.localAgents;
270278

packages/v2/core/src/core/core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,10 @@ export class CopilotKitCore {
329329
return this.agentRegistry.audioFileTranscriptionEnabled;
330330
}
331331

332+
get a2uiEnabled(): boolean {
333+
return this.agentRegistry.a2uiEnabled;
334+
}
335+
332336
/**
333337
* Configuration updates
334338
*/

packages/v2/react/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ export * from "./hooks";
1212
export * from "./providers";
1313
export * from "./types";
1414
export * from "./lib/react-core";
15+
export { createA2UIMessageRenderer } from "./a2ui/A2UIMessageRenderer";
16+
export type { A2UIMessageRendererOptions } from "./a2ui/A2UIMessageRenderer";
17+
export type { Theme as A2UITheme } from "@copilotkit/a2ui-renderer";

packages/v2/react/src/providers/CopilotKitProvider.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
MCPAppsActivityRenderer,
2222
MCPAppsActivityType,
2323
} from "../components/MCPAppsActivityRenderer";
24+
import { createA2UIMessageRenderer } from "../a2ui/A2UIMessageRenderer";
25+
import { viewerTheme } from "@copilotkit/a2ui-renderer";
26+
import type { Theme as A2UITheme } from "@copilotkit/a2ui-renderer";
2427
import { CopilotKitCoreReact } from "../lib/react-core";
2528
import type {
2629
ReactActivityMessageRenderer,
@@ -89,6 +92,26 @@ export interface CopilotKitProviderProps {
8992
code: CopilotKitCoreErrorCode;
9093
context: Record<string, any>;
9194
}) => void | Promise<void>;
95+
/**
96+
* Configuration for the A2UI (Agent-to-UI) renderer.
97+
* The built-in A2UI renderer is activated automatically when the runtime reports
98+
* that `a2ui` is configured in `CopilotRuntime`. This prop is optional and only
99+
* needed if you want to override the default theme.
100+
*
101+
* @example
102+
* ```tsx
103+
* <CopilotKit runtimeUrl="/api/copilotkit" a2ui={{ theme: myCustomTheme }}>
104+
* {children}
105+
* </CopilotKit>
106+
* ```
107+
*/
108+
a2ui?: {
109+
/**
110+
* Override the default A2UI viewer theme.
111+
* When omitted, the built-in `viewerTheme` from `@copilotkit/a2ui-renderer` is used.
112+
*/
113+
theme?: A2UITheme;
114+
};
92115
}
93116

94117
// Small helper to normalize array props to a stable reference and warn
@@ -133,8 +156,10 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
133156
showDevConsole = false,
134157
useSingleEndpoint = false,
135158
onError,
159+
a2ui,
136160
}) => {
137161
const [shouldRenderInspector, setShouldRenderInspector] = useState(false);
162+
const [runtimeA2UIEnabled, setRuntimeA2UIEnabled] = useState(false);
138163

139164
useEffect(() => {
140165
if (typeof window === "undefined") {
@@ -188,16 +213,25 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
188213
>(renderActivityMessages, "renderActivityMessages must be a stable array.");
189214

190215
// Built-in activity renderers that are always included
191-
const builtInActivityRenderers = useMemo<ReactActivityMessageRenderer<any>[]>(
192-
() => [
216+
const builtInActivityRenderers = useMemo<
217+
ReactActivityMessageRenderer<any>[]
218+
>(() => {
219+
const renderers: ReactActivityMessageRenderer<any>[] = [
193220
{
194221
activityType: MCPAppsActivityType,
195222
content: MCPAppsActivityContentSchema,
196223
render: MCPAppsActivityRenderer,
197224
},
198-
],
199-
[],
200-
);
225+
];
226+
227+
if (runtimeA2UIEnabled) {
228+
renderers.unshift(
229+
createA2UIMessageRenderer({ theme: a2ui?.theme ?? viewerTheme }),
230+
);
231+
}
232+
233+
return renderers;
234+
}, [runtimeA2UIEnabled, a2ui]);
201235

202236
// Combine user-provided activity renderers with built-in ones
203237
// User-provided renderers take precedence (come first) so they can override built-ins
@@ -347,6 +381,18 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
347381
}
348382
const copilotkit = copilotkitRef.current;
349383

384+
// Sync runtimeA2UIEnabled from the core once runtime info is fetched
385+
useEffect(() => {
386+
const subscription = copilotkit.subscribe({
387+
onRuntimeConnectionStatusChanged: () => {
388+
setRuntimeA2UIEnabled(copilotkit.a2uiEnabled);
389+
},
390+
});
391+
return () => {
392+
subscription.unsubscribe();
393+
};
394+
}, [copilotkit]);
395+
350396
// Subscribe to render tool calls changes to force re-renders
351397
const [, forceUpdate] = useReducer((x) => x + 1, 0);
352398

packages/v2/react/src/providers/__tests__/CopilotKitProvider.test.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,154 @@ describe("CopilotKitProvider", () => {
425425
});
426426
});
427427

428+
describe("a2ui prop", () => {
429+
const originalFetch = global.fetch;
430+
const originalWindow = (globalThis as { window?: unknown }).window;
431+
432+
beforeEach(() => {
433+
(globalThis as { window?: unknown }).window = {};
434+
});
435+
436+
afterEach(() => {
437+
global.fetch = originalFetch;
438+
if (originalWindow === undefined) {
439+
delete (globalThis as { window?: unknown }).window;
440+
} else {
441+
(globalThis as { window?: unknown }).window = originalWindow;
442+
}
443+
});
444+
445+
it("does not register an a2ui-surface renderer by default", () => {
446+
const { result } = renderHook(() => useCopilotKit(), {
447+
wrapper: ({ children }) => (
448+
<CopilotKitProvider>{children}</CopilotKitProvider>
449+
),
450+
});
451+
452+
const a2uiRenderer =
453+
result.current.copilotkit.renderActivityMessages.find(
454+
(r) => r.activityType === "a2ui-surface",
455+
);
456+
expect(a2uiRenderer).toBeUndefined();
457+
});
458+
459+
it("does not register an a2ui-surface renderer when a2ui.theme is provided but runtime has not signaled a2uiEnabled", () => {
460+
const customTheme = { components: {}, elements: {}, markdown: {} } as any;
461+
462+
const { result } = renderHook(() => useCopilotKit(), {
463+
wrapper: ({ children }) => (
464+
<CopilotKitProvider a2ui={{ theme: customTheme }}>
465+
{children}
466+
</CopilotKitProvider>
467+
),
468+
});
469+
470+
const a2uiRenderer =
471+
result.current.copilotkit.renderActivityMessages.find(
472+
(r) => r.activityType === "a2ui-surface",
473+
);
474+
expect(a2uiRenderer).toBeUndefined();
475+
});
476+
477+
it("registers an a2ui-surface renderer when runtime reports a2uiEnabled: true", async () => {
478+
global.fetch = vi.fn().mockResolvedValue({
479+
ok: true,
480+
json: async () => ({
481+
version: "1.0.0",
482+
agents: {},
483+
audioFileTranscriptionEnabled: false,
484+
a2uiEnabled: true,
485+
}),
486+
});
487+
488+
const { result } = renderHook(() => useCopilotKit(), {
489+
wrapper: ({ children }) => (
490+
<CopilotKitProvider runtimeUrl="http://localhost:3000/api">
491+
{children}
492+
</CopilotKitProvider>
493+
),
494+
});
495+
496+
await vi.waitFor(() => {
497+
const a2uiRenderer =
498+
result.current.copilotkit.renderActivityMessages.find(
499+
(r) => r.activityType === "a2ui-surface",
500+
);
501+
expect(a2uiRenderer).toBeDefined();
502+
});
503+
});
504+
505+
it("does not register an a2ui-surface renderer when runtime reports a2uiEnabled: false", async () => {
506+
global.fetch = vi.fn().mockResolvedValue({
507+
ok: true,
508+
json: async () => ({
509+
version: "1.0.0",
510+
agents: {},
511+
audioFileTranscriptionEnabled: false,
512+
a2uiEnabled: false,
513+
}),
514+
});
515+
516+
const { result } = renderHook(() => useCopilotKit(), {
517+
wrapper: ({ children }) => (
518+
<CopilotKitProvider runtimeUrl="http://localhost:3000/api">
519+
{children}
520+
</CopilotKitProvider>
521+
),
522+
});
523+
524+
// Let the connection settle
525+
await vi.waitFor(() => {
526+
expect(global.fetch).toHaveBeenCalled();
527+
});
528+
529+
const a2uiRenderer =
530+
result.current.copilotkit.renderActivityMessages.find(
531+
(r) => r.activityType === "a2ui-surface",
532+
);
533+
expect(a2uiRenderer).toBeUndefined();
534+
});
535+
536+
it("user-provided renderActivityMessages with activityType a2ui-surface takes precedence over built-in", async () => {
537+
global.fetch = vi.fn().mockResolvedValue({
538+
ok: true,
539+
json: async () => ({
540+
version: "1.0.0",
541+
agents: {},
542+
audioFileTranscriptionEnabled: false,
543+
a2uiEnabled: true,
544+
}),
545+
});
546+
547+
const userRenderer = {
548+
activityType: "a2ui-surface",
549+
content: {} as any,
550+
render: () => null,
551+
};
552+
553+
const { result } = renderHook(() => useCopilotKit(), {
554+
wrapper: ({ children }) => (
555+
<CopilotKitProvider
556+
runtimeUrl="http://localhost:3000/api"
557+
renderActivityMessages={[userRenderer]}
558+
>
559+
{children}
560+
</CopilotKitProvider>
561+
),
562+
});
563+
564+
await vi.waitFor(() => {
565+
const renderers =
566+
result.current.copilotkit.renderActivityMessages.filter(
567+
(r) => r.activityType === "a2ui-surface",
568+
);
569+
// Both present; user-provided comes first (index 0)
570+
expect(renderers.length).toBeGreaterThanOrEqual(1);
571+
expect(renderers[0].render).toBe(userRenderer.render);
572+
});
573+
});
574+
});
575+
428576
describe("Edge cases", () => {
429577
it("handles empty arrays for tools", () => {
430578
const { result } = renderHook(() => useCopilotKit(), {

packages/v2/runtime/src/__tests__/get-runtime-info.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("handleGetRuntimeInfo", () => {
3232
version: expect.any(String),
3333
agents: {},
3434
audioFileTranscriptionEnabled: false,
35+
a2uiEnabled: false,
3536
});
3637
});
3738

@@ -54,6 +55,7 @@ describe("handleGetRuntimeInfo", () => {
5455
version: expect.any(String),
5556
agents: {},
5657
audioFileTranscriptionEnabled: true,
58+
a2uiEnabled: false,
5759
});
5860
});
5961

@@ -88,9 +90,27 @@ describe("handleGetRuntimeInfo", () => {
8890
},
8991
},
9092
audioFileTranscriptionEnabled: true,
93+
a2uiEnabled: false,
9194
});
9295
});
9396

97+
it("should return a2uiEnabled: true when runtime has a2ui configured", async () => {
98+
const runtime = new CopilotRuntime({
99+
agents: {},
100+
a2ui: {},
101+
});
102+
103+
const response = await handleGetRuntimeInfo({
104+
runtime,
105+
request: mockRequest,
106+
});
107+
108+
expect(response.status).toBe(200);
109+
110+
const data = await response.json();
111+
expect(data.a2uiEnabled).toBe(true);
112+
});
113+
94114
it("should return 500 error when runtime.agents throws an error", async () => {
95115
const runtime = {
96116
get agents(): Record<string, AbstractAgent> {

packages/v2/runtime/src/handlers/get-runtime-info.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export async function handleGetRuntimeInfo({
2929
version: VERSION,
3030
agents: agentsDict,
3131
audioFileTranscriptionEnabled: !!runtime.transcriptionService,
32+
a2uiEnabled: !!runtime.a2ui,
3233
};
3334

3435
return new Response(JSON.stringify(runtimeInfo), {

packages/v2/shared/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ export interface RuntimeInfo {
2323
version: string;
2424
agents: Record<string, AgentDescription>;
2525
audioFileTranscriptionEnabled: boolean;
26+
a2uiEnabled?: boolean;
2627
}

0 commit comments

Comments
 (0)