mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(react-core): re-render consumers when the agent node changes
useAgentNodeName tracked the current node in a ref and returned nodeNameRef.current. Mutating a ref schedules no render, so a component reading useCoAgent().nodeName kept showing whichever node was current at its last render and never updated on its own -- it only appeared to work when something unrelated happened to re-render it. Backing the value with state fixes that. Adds the coverage the hook never had: transitions, run-start reset, run-error, and unsubscribe on unmount. Note this does NOT explain GH #1426 (interrupt agentMetadata.nodeName reporting the previous node). useInterrupt only evaluates the `enabled` predicate from a useEffect/useMemo keyed on its `pending` state, so the interrupt's own state update always forces a render before the predicate runs -- and the pre-fix code reads the ref correctly at that point. That report needs a different explanation; it is left open. Refs #1426 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Ben Taylor
parent
69bb0a9814
commit
ffd15801d6
@@ -0,0 +1,149 @@
|
||||
import { vi } from "vitest";
|
||||
import React from "react";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { AgentSubscriber } from "@ag-ui/client";
|
||||
import { useAgentNodeName } from "../use-agent-nodename";
|
||||
import { useLangGraphInterrupt } from "../use-langgraph-interrupt";
|
||||
|
||||
// The one subscriber the hook under test registers, captured so tests can
|
||||
// drive AG-UI events without a live agent.
|
||||
let subscriber: AgentSubscriber | null = null;
|
||||
const unsubscribe = vi.fn();
|
||||
|
||||
const mockAgent = {
|
||||
subscribe: vi.fn((s: AgentSubscriber) => {
|
||||
subscriber = s;
|
||||
return { unsubscribe };
|
||||
}),
|
||||
};
|
||||
|
||||
// Captured from the `useInterrupt` call `useLangGraphInterrupt` makes.
|
||||
let interruptArgs: any = null;
|
||||
|
||||
vi.mock("../../v2", () => ({
|
||||
useAgent: vi.fn(() => ({ agent: mockAgent })),
|
||||
useInterrupt: vi.fn((args: any) => {
|
||||
interruptArgs = args;
|
||||
}),
|
||||
useCopilotChatConfiguration: vi.fn(() => ({
|
||||
agentId: "default",
|
||||
threadId: "thread-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
function emitStepStarted(stepName: string) {
|
||||
subscriber?.onStepStartedEvent?.({ event: { stepName } } as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
subscriber = null;
|
||||
interruptArgs = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useAgentNodeName", () => {
|
||||
it("re-renders consumers on every node transition", () => {
|
||||
const renderedNodeNames: string[] = [];
|
||||
|
||||
const Component: React.FC = () => {
|
||||
renderedNodeNames.push(useAgentNodeName("default"));
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Component />);
|
||||
|
||||
// Nothing else here triggers a render, so every entry after the first
|
||||
// exists only because the node change itself scheduled one.
|
||||
act(() => emitStepStarted("call_model_node"));
|
||||
act(() => emitStepStarted("process_feedback_node"));
|
||||
act(() => {
|
||||
subscriber?.onRunFinishedEvent?.({} as any);
|
||||
});
|
||||
|
||||
expect(renderedNodeNames).toEqual([
|
||||
"start",
|
||||
"call_model_node",
|
||||
"process_feedback_node",
|
||||
"end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports 'end' when a run errors", () => {
|
||||
const renderedNodeNames: string[] = [];
|
||||
const Component: React.FC = () => {
|
||||
renderedNodeNames.push(useAgentNodeName("default"));
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Component />);
|
||||
act(() => emitStepStarted("call_model_node"));
|
||||
act(() => {
|
||||
subscriber?.onRunErrorEvent?.({} as any);
|
||||
});
|
||||
|
||||
expect(renderedNodeNames.at(-1)).toBe("end");
|
||||
});
|
||||
|
||||
it("resets to 'start' when a new run begins", () => {
|
||||
const renderedNodeNames: string[] = [];
|
||||
const Component: React.FC = () => {
|
||||
renderedNodeNames.push(useAgentNodeName("default"));
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Component />);
|
||||
act(() => emitStepStarted("call_model_node"));
|
||||
expect(renderedNodeNames.at(-1)).toBe("call_model_node");
|
||||
|
||||
act(() => {
|
||||
subscriber?.onRunStartedEvent?.({} as any);
|
||||
});
|
||||
expect(renderedNodeNames.at(-1)).toBe("start");
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const Component: React.FC = () => {
|
||||
useAgentNodeName("default");
|
||||
return null;
|
||||
};
|
||||
|
||||
const { unmount } = render(<Component />);
|
||||
unmount();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLangGraphInterrupt agentMetadata", () => {
|
||||
// Characterization, not a regression test. `useInterrupt` only evaluates
|
||||
// `enabled` from a `useEffect`/`useMemo` keyed on its `pending` state, so a
|
||||
// render always lands between the interrupt arriving and the predicate
|
||||
// running. This pins that contract down, since nothing covered it before.
|
||||
it("carries the agent, thread, and current node", () => {
|
||||
let captured: any = null;
|
||||
|
||||
const Component: React.FC = () => {
|
||||
useLangGraphInterrupt({
|
||||
enabled: ({ agentMetadata }) => {
|
||||
captured = agentMetadata;
|
||||
return true;
|
||||
},
|
||||
render: () => "interrupt",
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
render(<Component />);
|
||||
|
||||
act(() => emitStepStarted("process_feedback_node"));
|
||||
act(() => {
|
||||
interruptArgs.enabled({ value: {} });
|
||||
});
|
||||
|
||||
expect(captured).toEqual({
|
||||
agentName: "default",
|
||||
threadId: "thread-1",
|
||||
nodeName: "process_feedback_node",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentSubscriber } from "@ag-ui/client";
|
||||
import { useAgent } from "../v2";
|
||||
|
||||
/**
|
||||
* Tracks the node the agent is currently executing.
|
||||
*
|
||||
* Backed by state rather than a ref: mutating a ref schedules no render, so
|
||||
* consumers such as `useCoAgent().nodeName` kept reporting whichever node was
|
||||
* current at their last render and never updated on their own.
|
||||
*/
|
||||
export function useAgentNodeName(agentName?: string) {
|
||||
const { agent } = useAgent({ agentId: agentName });
|
||||
const nodeNameRef = useRef<string>("start");
|
||||
const [nodeName, setNodeName] = useState<string>("start");
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
const subscriber: AgentSubscriber = {
|
||||
onStepStartedEvent: ({ event }) => {
|
||||
nodeNameRef.current = event.stepName;
|
||||
setNodeName(event.stepName);
|
||||
},
|
||||
onRunStartedEvent: () => {
|
||||
nodeNameRef.current = "start";
|
||||
setNodeName("start");
|
||||
},
|
||||
onRunFinishedEvent: () => {
|
||||
nodeNameRef.current = "end";
|
||||
setNodeName("end");
|
||||
},
|
||||
onRunErrorEvent: () => {
|
||||
nodeNameRef.current = "end";
|
||||
setNodeName("end");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,5 +36,5 @@ export function useAgentNodeName(agentName?: string) {
|
||||
};
|
||||
}, [agent]);
|
||||
|
||||
return nodeNameRef.current;
|
||||
return nodeName;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user