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:
Benjamin Taylor
2026-08-17 09:41:07 -05:00
committed by Ben Taylor
parent 69bb0a9814
commit ffd15801d6
2 changed files with 163 additions and 7 deletions
@@ -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;
}