Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/integrations/agno/agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"openai>=1.88.0",
"yfinance>=0.2.63",
"fastapi>=0.115.13",
"python-multipart>=0.0.20",
"uvicorn>=0.34.3",
"ag-ui-protocol>=0.1.8",
"packaging>=25.0.0",
Expand Down
4 changes: 3 additions & 1 deletion examples/integrations/agno/agent/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CopilotKitProvider } from "../../providers/CopilotKitProvider";
import { useAgent } from "../use-agent";
import {
MockStepwiseAgent,
renderWithCopilotKit,
} from "../../__tests__/utils/test-helpers";

/**
* Regression coverage for #5000: `agent.subscribe()` used to throw
* "Cannot read properties of undefined (reading 'subscribers')" when called on
* mount while the runtime was still connecting, because there was no
* fully-constructed agent to subscribe to and no signal telling consumers when
* it was safe. `useAgent` now always returns a fully-constructed agent
* (provisional while connecting) and exposes an `isReady` flag.
*/
describe("useAgent subscribe / isReady (#5000)", () => {
const originalFetch = global.fetch;
const originalWindow = (globalThis as { window?: unknown }).window;

beforeEach(() => {
// Preserve jsdom's real window/document (needed by testing-library's
// waitFor) while ensuring `window` is defined so the runtime connects
// instead of taking the SSR early-return.
(globalThis as { window?: unknown }).window =
(globalThis as { window?: unknown }).window ?? {};
});

afterEach(() => {
vi.restoreAllMocks();
global.fetch = originalFetch;
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
});

it("subscribe() in an effect does not throw while the runtime is connecting", async () => {
// fetch never resolves → runtime stays Connecting → provisional agent
global.fetch = vi.fn().mockReturnValue(new Promise(() => {})) as any;
let caught: Error | null = null;

function TestComponent() {
const { agent, isReady } = useAgent({ agentId: "test-agent" });
React.useEffect(() => {
try {
const sub = agent.subscribe({ onRunFinalized: () => {} });
return () => sub.unsubscribe();
} catch (e) {
caught = e as Error;
}
}, [agent]);
return <div data-testid="ready">{String(isReady)}</div>;
}

render(
<CopilotKitProvider runtimeUrl="http://localhost:59999/x">
<TestComponent />
</CopilotKitProvider>,
);

const el = await screen.findByTestId("ready");
await new Promise((r) => setTimeout(r, 0));

expect(caught).toBeNull();
// Provisional agent while connecting → not ready yet.
expect(el.textContent).toBe("false");
});

it("subscribe() during render does not throw while the runtime is connecting", async () => {
global.fetch = vi.fn().mockReturnValue(new Promise(() => {})) as any;
let caught: Error | null = null;

function TestComponent() {
const { agent } = useAgent({ agentId: "test-agent" });
try {
agent.subscribe({ onRunFinalized: () => {} });
} catch (e) {
caught = e as Error;
}
return <div data-testid="ok">{agent.agentId}</div>;
}

render(
<CopilotKitProvider runtimeUrl="http://localhost:59999/x">
<TestComponent />
</CopilotKitProvider>,
);

await screen.findByTestId("ok");
expect(caught).toBeNull();
});

it("isReady flips false -> true once the runtime syncs, swapping the provisional agent for the real one", async () => {
const runtimeInfo = {
version: "1.0.0",
audioFileTranscriptionEnabled: false,
agents: {
"test-agent": {
name: "test-agent",
description: "Test agent",
capabilities: {},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => runtimeInfo,
}) as any;

const readyValues: boolean[] = [];
const subscribedAgents: unknown[] = [];

function TestComponent() {
const { agent, isReady } = useAgent({ agentId: "test-agent" });
readyValues.push(isReady);
React.useEffect(() => {
// Only subscribe once the real agent is bound (the reporter's pattern).
if (!isReady) return;
subscribedAgents.push(agent);
const sub = agent.subscribe({ onRunFinalized: () => {} });
return () => sub.unsubscribe();
}, [agent, isReady]);
return <div data-testid="ready">{String(isReady)}</div>;
}

render(
<CopilotKitProvider runtimeUrl="http://localhost:3000/api">
<TestComponent />
</CopilotKitProvider>,
);

const el = await screen.findByTestId("ready");
// First render is the provisional (connecting) agent.
expect(readyValues[0]).toBe(false);

// After the runtime /info sync resolves, isReady becomes true.
await waitFor(() => expect(el.textContent).toBe("true"));

// The guarded effect only ever subscribed to the real (ready) agent.
expect(subscribedAgents.length).toBeGreaterThan(0);
});

it("isReady is true and subscribe() works for a locally-registered agent", async () => {
const agent = new MockStepwiseAgent();
let caught: Error | null = null;

function TestComponent() {
const { agent: hookAgent, isReady } = useAgent();
React.useEffect(() => {
try {
const sub = hookAgent.subscribe({ onRunFinalized: () => {} });
return () => sub.unsubscribe();
} catch (e) {
caught = e as Error;
}
}, [hookAgent]);
return <div data-testid="ready">{String(isReady)}</div>;
}

renderWithCopilotKit({ agent, children: <TestComponent /> });

const el = await screen.findByTestId("ready");
await new Promise((r) => setTimeout(r, 0));
expect(el.textContent).toBe("true");
expect(caught).toBeNull();
});
});
30 changes: 24 additions & 6 deletions packages/react-core/src/v2/hooks/use-agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,15 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
new Map(),
);

const agent: AbstractAgent = useMemo(() => {
const { agent, isReady } = useMemo<{
agent: AbstractAgent;
isReady: boolean;
}>(() => {
const existing = copilotkit.getAgent(resolvedAgentId);
if (existing) {
// Real agent found — clear any cached provisional for this ID
provisionalAgentCache.current.delete(resolvedAgentId);
return existing;
return { agent: existing, isReady: true };
}

const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
Expand All @@ -105,7 +108,7 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
if (cached) {
// Update headers on the cached agent in case they changed
copilotkit.applyHeadersToAgent(cached);
return cached;
return { agent: cached, isReady: false };
}

const provisional = new ProxiedCopilotRuntimeAgent({
Expand All @@ -117,7 +120,7 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
// Apply current headers so runs/connects inherit them
copilotkit.applyHeadersToAgent(provisional);
provisionalAgentCache.current.set(resolvedAgentId, provisional);
return provisional;
return { agent: provisional, isReady: false };
}

// Runtime is in Error state — return a provisional agent instead of throwing.
Expand All @@ -132,7 +135,7 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
const cached = provisionalAgentCache.current.get(resolvedAgentId);
if (cached) {
copilotkit.applyHeadersToAgent(cached);
return cached;
return { agent: cached, isReady: false };
}
const provisional = new ProxiedCopilotRuntimeAgent({
runtimeUrl: copilotkit.runtimeUrl,
Expand All @@ -142,7 +145,7 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
});
copilotkit.applyHeadersToAgent(provisional);
provisionalAgentCache.current.set(resolvedAgentId, provisional);
return provisional;
return { agent: provisional, isReady: false };
}

// No runtime configured and agent doesn't exist — this is a configuration error.
Expand Down Expand Up @@ -253,5 +256,20 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {

return {
agent,
/**
* Whether `agent` is the real, runtime-synced (or locally-registered) agent
* rather than a provisional stand-in returned while the runtime is still
* connecting (or in an error state).
*
* `agent` is always a fully-constructed `AbstractAgent`, so calling
* `agent.subscribe(...)`, `agent.setState(...)`, etc. is always safe. But
* while `isReady` is `false` the instance is a placeholder that will be
* swapped for the real agent once the runtime `/info` sync resolves, at
* which point `agent` changes reference and dependent effects re-run.
* Guard on `isReady` when you only want to act against the real agent —
* e.g. subscribing to run-lifecycle events you don't want to miss during
* the provisional window (#5000).
*/
isReady,
};
}
Loading
Loading