fix(react-core): make agentMetadata.nodeName match the node where the interrupt originates (#6488)

## Problem

`useAgentNodeName` must update React consumers when AG-UI node events
arrive, and `useLangGraphInterrupt.enabled()` must receive the node
where an interrupt actually occurred.

Current `main` includes the basic ref-to-state reactivity fix from
[ffd1580](https://github.com/CopilotKit/CopilotKit/commit/ffd15801d6d),
but that commit explicitly leaves #1426 open: a later `RUN_FINISHED` can
still replace the interrupting node with `"end"`, and v1 consumers can
still be hidden behind `useCoAgent`'s memoized return value.

## What remains in this PR

Rebased onto current `main` (`e9387e0`) after the v1 source migration,
this PR contains only the remaining behavior:

- Preserve the last active node when `RUN_FINISHED` reports `outcome:
"interrupt"`.
- Preserve it for the legacy `on_interrupt` custom-event flow as well.
- Continue transitioning successful and failed runs to `"end"`; reset
new runs and agent switches to `"start"`.
- Add `nodeName` to the `useCoAgent` return-value memo dependencies so
v1 consumers receive the reactive update.
- Share `INTERRUPT_EVENT_NAME` between the hook and interrupt
implementation.

The public hook signatures and AG-UI protocol remain unchanged.

## Preview workflow

- Disabled pkg-pr-new's generated all-package StackBlitz template;
package preview install URLs remain available.

## Changes

- `packages/react-core/src/v1-deprecated/hooks/use-agent-nodename.ts`
- `packages/react-core/src/v1-deprecated/hooks/use-coagent.ts`
-
`packages/react-core/src/v1-deprecated/hooks/__tests__/use-agent-nodename.test.tsx`
- `packages/react-core/src/v2/types/interrupt.ts`
- `packages/react-core/src/v2/hooks/use-interrupt.tsx`
- `.github/workflows/publish-commit.yml`

## Verification

- Full React Core Vitest suite: **131 files, 1520 tests passed**.
- Preview workflow: Nx formatting and YAML parsing passed.
- `nx run @copilotkit/react-core:check-types --skipNxCache`: passed,
including all 33 dependency tasks.
- `git diff --check origin/main...HEAD`: passed.
- The composite React Core test target then reaches the existing
Windows-only script baseline: 8 path-normalization failures plus 2
symlink-permission failures. These are outside this PR's files; the
complete Vitest suite passes before that script stage.

## Scope

This intentionally does not change the AG-UI event protocol, runtime
event ordering, HITL workflow, v1/v2 compatibility layer, or the
separate node tracking in `use-coagent-state-render-bridge.tsx`.

Fixes #1426
This commit is contained in:
Ben Taylor
2026-08-30 10:05:27 -05:00
committed by GitHub
6 changed files with 130 additions and 19 deletions
+5 -1
View File
@@ -62,4 +62,8 @@ jobs:
- name: Build
run: pnpm run build
- run: npx pkg-pr-new publish --pnpm --packageManager pnpm "./packages/*"
# The generated default template installs every published package into a
# single Node sandbox. That mixes browser, server, Angular, Vue, and React
# Native packages, making the StackBlitz preview unusable. Package preview
# URLs remain available without the default template.
- run: npx pkg-pr-new publish --pnpm --packageManager pnpm --no-template "./packages/*"
@@ -35,6 +35,12 @@ function emitStepStarted(stepName: string) {
subscriber?.onStepStartedEvent?.({ event: { stepName } } as any);
}
function emitRunFinished(outcome: "success" | "interrupt") {
subscriber?.onRunFinishedEvent?.({
outcome,
} as any);
}
beforeEach(() => {
subscriber = null;
interruptArgs = null;
@@ -56,9 +62,7 @@ describe("useAgentNodeName", () => {
// exists only because the node change itself scheduled one.
act(() => emitStepStarted("call_model_node"));
act(() => emitStepStarted("process_feedback_node"));
act(() => {
subscriber?.onRunFinishedEvent?.({} as any);
});
act(() => emitRunFinished("success"));
expect(renderedNodeNames).toEqual([
"start",
@@ -101,6 +105,42 @@ describe("useAgentNodeName", () => {
expect(renderedNodeNames.at(-1)).toBe("start");
});
it("keeps the active node after a standard interrupt", () => {
const renderedNodeNames: string[] = [];
const Component: React.FC = () => {
renderedNodeNames.push(useAgentNodeName("default"));
return null;
};
render(<Component />);
act(() => emitStepStarted("process_feedback_node"));
act(() => emitRunFinished("interrupt"));
expect(renderedNodeNames.at(-1)).toBe("process_feedback_node");
});
it("keeps the active node after a legacy interrupt", () => {
const renderedNodeNames: string[] = [];
const Component: React.FC = () => {
renderedNodeNames.push(useAgentNodeName("default"));
return null;
};
render(<Component />);
act(() => emitStepStarted("process_feedback_node"));
act(() => {
subscriber?.onCustomEvent?.({
event: {
name: "on_interrupt",
value: { question: "Continue?" },
},
} as any);
});
act(() => emitRunFinished("success"));
expect(renderedNodeNames.at(-1)).toBe("process_feedback_node");
});
it("unsubscribes on unmount", () => {
const Component: React.FC = () => {
useAgentNodeName("default");
@@ -115,10 +155,8 @@ describe("useAgentNodeName", () => {
});
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.
// Regression coverage for #1426: RUN_FINISHED must not replace the active
// node with "end" before the enabled predicate reads agent metadata.
it("carries the agent, thread, and current node", () => {
let captured: any = null;
@@ -136,6 +174,7 @@ describe("useLangGraphInterrupt agentMetadata", () => {
render(<Component />);
act(() => emitStepStarted("process_feedback_node"));
act(() => emitRunFinished("interrupt"));
act(() => {
interruptArgs.enabled({ value: {} });
});
@@ -1,32 +1,97 @@
import { useEffect, useState } from "react";
import type { AgentSubscriber } from "@ag-ui/client";
import { useAgent } from "../../v2";
import { INTERRUPT_EVENT_NAME } from "../../v2/types/interrupt";
interface AgentNodeNameState {
nodeName: string;
lastActiveNodeName: string;
hasLegacyInterrupt: boolean;
}
type AgentNodeNameEvent =
| { type: "reset" }
| { type: "runStarted" }
| { type: "stepStarted"; nodeName: string }
| { type: "legacyInterruptReceived" }
| { type: "runFinished"; outcome: "success" | "interrupt" }
| { type: "runError" };
type AgentNodeNameTransition = (
state: AgentNodeNameState,
event: AgentNodeNameEvent,
) => AgentNodeNameState;
const initialAgentNodeNameState: AgentNodeNameState = {
nodeName: "start",
lastActiveNodeName: "start",
hasLegacyInterrupt: false,
};
const transitionAgentNodeName: AgentNodeNameTransition = (state, event) => {
switch (event.type) {
case "reset":
case "runStarted":
return initialAgentNodeNameState;
case "stepStarted":
return {
...state,
nodeName: event.nodeName,
lastActiveNodeName: event.nodeName,
};
case "legacyInterruptReceived":
return { ...state, hasLegacyInterrupt: true };
case "runFinished":
if (event.outcome === "interrupt" || state.hasLegacyInterrupt) {
return {
...state,
nodeName: state.lastActiveNodeName,
hasLegacyInterrupt: false,
};
}
return { ...state, nodeName: "end", hasLegacyInterrupt: false };
case "runError":
return { ...state, nodeName: "end", hasLegacyInterrupt: false };
}
};
/**
* 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.
* current at their last render and never updated on their own. Interrupt-aware
* transitions keep the last active node available while an interrupt is pending.
*/
export function useAgentNodeName(agentName?: string) {
const { agent } = useAgent({ agentId: agentName });
const [nodeName, setNodeName] = useState<string>("start");
const [nodeNameState, setNodeNameState] = useState(initialAgentNodeNameState);
useEffect(() => {
const transition = (event: AgentNodeNameEvent) => {
setNodeNameState((state) => transitionAgentNodeName(state, event));
};
transition({ type: "reset" });
if (!agent) return;
const subscriber: AgentSubscriber = {
onStepStartedEvent: ({ event }) => {
setNodeName(event.stepName);
transition({ type: "stepStarted", nodeName: event.stepName });
},
onRunStartedEvent: () => {
setNodeName("start");
transition({ type: "runStarted" });
},
onRunFinishedEvent: () => {
setNodeName("end");
onRunFinishedEvent: ({ outcome }) => {
transition({ type: "runFinished", outcome });
},
onRunErrorEvent: () => {
setNodeName("end");
transition({ type: "runError" });
},
onCustomEvent: ({ event }) => {
if (event.name === INTERRUPT_EVENT_NAME) {
transition({ type: "legacyInterruptReceived" });
}
},
};
@@ -34,7 +99,7 @@ export function useAgentNodeName(agentName?: string) {
return () => {
subscription.unsubscribe();
};
}, [agent]);
}, [agent, agentName]);
return nodeName;
return nodeNameState.nodeName;
}
@@ -393,6 +393,7 @@ export function useCoAgent<T = any>(
agent?.threadId,
agent?.isRunning,
agent?.agentId,
nodeName,
handleStateUpdate,
options.name,
]);
@@ -11,6 +11,7 @@ import { ɵInterruptState } from "@copilotkit/core";
import type { ɵPendingInterrupt } from "@copilotkit/core";
import { useCopilotKit } from "../context";
import { useAgent } from "./use-agent";
import { INTERRUPT_EVENT_NAME } from "../types/interrupt";
import type {
InterruptEvent,
InterruptRenderProps,
@@ -26,8 +27,6 @@ export type {
Interrupt,
};
const INTERRUPT_EVENT_NAME = "on_interrupt";
/**
* Normalized pending interrupt. `legacy` carries the custom-event payload;
* `standard` carries the AG-UI `outcome:"interrupt"` interrupts array.
@@ -2,6 +2,9 @@ import type { Interrupt, ResumeEntry, RunAgentResult } from "@ag-ui/client";
export type { Interrupt, ResumeEntry };
/** Name of the legacy custom event agents emit to signal an interrupt. */
export const INTERRUPT_EVENT_NAME = "on_interrupt";
/** Legacy custom-event interrupt payload (agent emits a custom `on_interrupt` event). */
export interface InterruptEvent<TValue = unknown> {
name: string;