Skip to content

Commit 66001fc

Browse files
authored
refactor(runtime): Simplify connect and run handlers in the Intelligence runtime (CopilotKit#4258)
<!-- Thank you for sending the PR! We appreciate you spending the time to work on these changes. Help us understand your motivation by explaining why you decided to make this change. **Please PLEASE reach out to us first before starting any significant work on new or existing features.** By the time you've gotten here, you're looking at creating a pull request so hopefully we're not too late. We love community contributions! That said, we want to make sure we're all on the same page before you start. Investing a lot of time and effort just to find out it doesn't align with the upstream project feels awful, and we don't want that to happen. It also helps to make sure the work you're planning isn't already in progress. As described in our contributing guide, please file an issue first: https://github.com/ag-ui-protocol/ag-ui/issues Or, reach out to us on Discord: https://discord.com/invite/6dffbvGU3D You can learn more about contributing to copilotkit here: https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md Happy contributing! --> ## What does this PR do? (Describe the changes introduced in this PR) ## Related PRs and Issues - (Direct link to related PR or issue, if relevant) ## Checklist - [ ] I have read the [Contribution Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md) - [ ] If the PR changes or adds functionality, I have updated the relevant documentation
2 parents 2918391 + ae978b9 commit 66001fc

15 files changed

Lines changed: 1566 additions & 1041 deletions

File tree

packages/core/src/__tests__/intelligence-agent.test.ts

Lines changed: 359 additions & 552 deletions
Large diffs are not rendered by default.

packages/core/src/intelligence-agent.ts

Lines changed: 234 additions & 175 deletions
Large diffs are not rendered by default.

packages/react-core/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx

Lines changed: 193 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { z } from "zod";
4+
import { EventType } from "@ag-ui/client";
45
import {
56
MockReconnectableAgent,
67
MockStepwiseAgent,
@@ -10,34 +11,149 @@ import {
1011
runStartedEvent,
1112
testId,
1213
} from "../../../__tests__/utils/test-helpers";
13-
import { ReactActivityMessageRenderer } from "../../../types";
14+
import type { ReactActivityMessageRenderer } from "../../../types";
1415
import {
1516
CopilotChatConfigurationProvider,
1617
CopilotKitProvider,
1718
useCopilotKit,
1819
} from "../../../providers";
19-
import { AbstractAgent } from "@ag-ui/client";
20+
import type { AbstractAgent } from "@ag-ui/client";
2021
import { IntelligenceAgent } from "@copilotkit/core";
2122
import { getThreadClone } from "../../../hooks/use-agent";
2223
import { createA2UIMessageRenderer } from "../../../a2ui/A2UIMessageRenderer";
2324
import type { Theme } from "@copilotkit/a2ui-renderer";
2425
import { CopilotChat } from "..";
2526

26-
const { mockWebsandboxCreate, mockWebsandboxDestroy } = vi.hoisted(() => {
27+
const {
28+
mockWebsandboxCreate,
29+
mockWebsandboxDestroy,
30+
mockPhoenixSockets,
31+
MockPhoenixSocket,
32+
} = vi.hoisted(() => {
2733
const mockDestroy = vi.fn();
28-
const mockCreate = vi.fn(() => ({
34+
const mockCreate = vi.fn((..._args: unknown[]) => ({
2935
iframe: document.createElement("iframe"),
3036
promise: Promise.resolve(),
3137
run: vi.fn().mockResolvedValue(undefined),
3238
destroy: mockDestroy,
3339
}));
40+
const mockSockets: MockPhoenixSocket[] = [];
41+
42+
class MockPhoenixPush {
43+
private callbacks = new Map<string, (response?: unknown) => void>();
44+
45+
receive(
46+
status: string,
47+
callback: (response?: unknown) => void,
48+
): MockPhoenixPush {
49+
this.callbacks.set(status, callback);
50+
return this;
51+
}
52+
53+
trigger(status: string, response?: unknown): void {
54+
this.callbacks.get(status)?.(response);
55+
}
56+
}
57+
58+
class MockPhoenixChannel {
59+
public topic: string;
60+
public params: Record<string, unknown>;
61+
public left = false;
62+
63+
private handlers = new Map<
64+
string,
65+
Array<{ ref: number; callback: (payload: unknown) => void }>
66+
>();
67+
private joinPush = new MockPhoenixPush();
68+
private nextRef = 1;
69+
70+
constructor(topic: string, params: Record<string, unknown>) {
71+
this.topic = topic;
72+
this.params = params;
73+
}
74+
75+
on(event: string, callback: (payload: unknown) => void): number {
76+
if (!this.handlers.has(event)) {
77+
this.handlers.set(event, []);
78+
}
79+
const ref = this.nextRef;
80+
this.nextRef += 1;
81+
this.handlers.get(event)?.push({ ref, callback });
82+
return ref;
83+
}
84+
85+
off(event: string, ref?: number): void {
86+
if (ref === undefined) {
87+
this.handlers.delete(event);
88+
return;
89+
}
90+
this.handlers.set(
91+
event,
92+
(this.handlers.get(event) ?? []).filter(
93+
(handler) => handler.ref !== ref,
94+
),
95+
);
96+
}
97+
98+
join(): MockPhoenixPush {
99+
return this.joinPush;
100+
}
101+
102+
leave(): void {
103+
this.left = true;
104+
}
105+
106+
triggerJoin(status: string, response?: unknown): void {
107+
this.joinPush.trigger(status, response);
108+
}
109+
110+
serverPush(event: string, payload: unknown): void {
111+
for (const { callback } of this.handlers.get(event) ?? []) {
112+
callback(payload);
113+
}
114+
}
115+
}
116+
117+
class MockPhoenixSocket {
118+
public channels: MockPhoenixChannel[] = [];
119+
120+
constructor(
121+
public url: string,
122+
public opts: Record<string, unknown>,
123+
) {
124+
mockSockets.push(this);
125+
}
126+
127+
connect(): void {}
128+
129+
disconnect(): void {}
130+
131+
onOpen(): void {}
132+
133+
onError(): void {}
134+
135+
channel(
136+
topic: string,
137+
params: Record<string, unknown>,
138+
): MockPhoenixChannel {
139+
const channel = new MockPhoenixChannel(topic, params);
140+
this.channels.push(channel);
141+
return channel;
142+
}
143+
}
34144

35145
return {
36146
mockWebsandboxCreate: mockCreate,
37147
mockWebsandboxDestroy: mockDestroy,
148+
mockPhoenixSockets: mockSockets,
149+
MockPhoenixSocket,
38150
};
39151
});
40152

153+
vi.mock("phoenix", () => ({
154+
Socket: MockPhoenixSocket,
155+
}));
156+
41157
vi.mock("@jetbrains/websandbox", () => ({
42158
default: {
43159
create: (...args: unknown[]) => mockWebsandboxCreate(...args),
@@ -329,66 +445,22 @@ describe("CopilotChat activity message rendering", () => {
329445
});
330446
});
331447

332-
it("restores a completed A2UI surface from an IntelligenceAgent /connect bootstrap plan", async () => {
448+
it("restores a completed A2UI surface from IntelligenceAgent /connect gateway replay", async () => {
333449
const threadId = testId("intelligence-connect-thread");
334450
const surfaceId = testId("intelligence-connect-surface");
335451
const fetchMock = vi.fn().mockResolvedValueOnce(
336452
jsonResponse({
337-
mode: "bootstrap",
338-
latestEventId: "event-3",
339-
events: [
340-
{
341-
type: "RUN_STARTED",
342-
threadId,
343-
run_id: "backend-run-1",
344-
input: {
345-
messages: [
346-
{
347-
id: testId("connect-user-message"),
348-
role: "user",
349-
content: "show me the restored ui",
350-
},
351-
],
352-
},
353-
},
354-
{
355-
type: "ACTIVITY_SNAPSHOT",
356-
messageId: testId("connect-a2ui-activity"),
357-
activityType: "a2ui-surface",
358-
content: {
359-
a2ui_operations: [
360-
{
361-
version: "v0.9",
362-
createSurface: {
363-
surfaceId,
364-
catalogId:
365-
"https://a2ui.org/specification/v0_9/basic_catalog.json",
366-
},
367-
},
368-
{
369-
version: "v0.9",
370-
updateComponents: {
371-
surfaceId,
372-
components: [
373-
{
374-
id: "root",
375-
component: "Text",
376-
text: "Restored dashboard",
377-
variant: "body",
378-
},
379-
],
380-
},
381-
},
382-
],
383-
},
384-
},
385-
{
386-
type: "RUN_FINISHED",
387-
},
388-
],
453+
threadId,
454+
runId: null,
455+
joinToken: "join-token-1",
456+
realtime: {
457+
clientUrl: "ws://localhost:4000/client",
458+
topic: `thread:${threadId}`,
459+
},
389460
}),
390461
);
391462
vi.stubGlobal("fetch", fetchMock);
463+
mockPhoenixSockets.length = 0;
392464

393465
const agent = new IntelligenceAgent({
394466
url: "ws://localhost:4000/client",
@@ -409,6 +481,70 @@ describe("CopilotChat activity message rendering", () => {
409481
await waitFor(() => {
410482
expect(fetchMock).toHaveBeenCalledTimes(1);
411483
});
484+
485+
await waitFor(() => {
486+
expect(mockPhoenixSockets).toHaveLength(1);
487+
expect(mockPhoenixSockets[0]?.channels).toHaveLength(1);
488+
});
489+
490+
const channel = mockPhoenixSockets[0]!.channels[0]!;
491+
expect(channel.topic).toBe(`thread:${threadId}`);
492+
expect(channel.params).toEqual({
493+
stream_mode: "connect",
494+
last_seen_event_id: null,
495+
});
496+
497+
channel.triggerJoin("ok");
498+
channel.serverPush("ag_ui_event", {
499+
type: EventType.RUN_STARTED,
500+
threadId,
501+
run_id: "backend-run-1",
502+
input: {
503+
messages: [
504+
{
505+
id: testId("connect-user-message"),
506+
role: "user",
507+
content: "show me the restored ui",
508+
},
509+
],
510+
},
511+
});
512+
channel.serverPush("ag_ui_event", {
513+
type: EventType.ACTIVITY_SNAPSHOT,
514+
messageId: testId("connect-a2ui-activity"),
515+
activityType: "a2ui-surface",
516+
content: {
517+
a2ui_operations: [
518+
{
519+
version: "v0.9",
520+
createSurface: {
521+
surfaceId,
522+
catalogId:
523+
"https://a2ui.org/specification/v0_9/basic_catalog.json",
524+
},
525+
},
526+
{
527+
version: "v0.9",
528+
updateComponents: {
529+
surfaceId,
530+
components: [
531+
{
532+
id: "root",
533+
component: "Text",
534+
text: "Restored dashboard",
535+
variant: "body",
536+
},
537+
],
538+
},
539+
},
540+
],
541+
},
542+
});
543+
channel.serverPush("ag_ui_event", {
544+
type: EventType.RUN_FINISHED,
545+
});
546+
channel.serverPush("stream_idle", { latestEventId: "event-3" });
547+
412548
await waitFor(() => {
413549
expect(screen.getByText("show me the restored ui")).toBeDefined();
414550
});

0 commit comments

Comments
 (0)