mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user