feat: create use-interrupt hook (#3184)

Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ran Shemtov
2026-02-24 15:50:21 +01:00
committed by GitHub
parent 9bd041888e
commit d77f3475a6
49 changed files with 4927 additions and 577 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@copilotkit/react-core": patch
"@copilotkitnext/react": patch
"@copilotkitnext/core": patch
---
Added in the useInterrupt hook
+1
View File
@@ -42,3 +42,4 @@ debug-storybook.log
storybook-static
coverage
.turbo
.langgraph_api
@@ -12,6 +12,8 @@
"---Frontend Tools---",
"useFrontendTool",
"useHumanInTheLoop",
"useInterrupt",
"useRenderToolCall",
"---Suggestions---",
"useSuggestions",
"useConfigureSuggestions",
@@ -0,0 +1,135 @@
---
title: "useInterrupt"
description: "React hook for handling agent interrupt events and resuming execution with user input"
---
## Overview
`useInterrupt` listens for agent custom events named `on_interrupt`, captures the latest interrupt payload for a run, and renders interrupt UI once the run finalizes. Your UI can call `resolve(response)` to resume the agent with a resume payload.
By default, interrupt UI is rendered inside `<CopilotChat>` automatically. If you set `renderInChat: false`, the hook returns the element so you can place it manually.
`event.value` is typed as `any` since the interrupt payload shape depends on your agent. Type-narrow it in your callbacks (e.g. `handler`, `enabled`, `render`) as needed.
## Signature
```tsx
import { useInterrupt } from "@copilotkit/react-core/v2";
function useInterrupt<
TResult = never,
TRenderInChat extends boolean | undefined = undefined,
>(
config: UseInterruptConfig<any, TResult, TRenderInChat>,
): TRenderInChat extends false
? React.ReactElement | null
: TRenderInChat extends true | undefined
? void
: React.ReactElement | null | void;
```
## Parameters
<PropertyReference name="config" type="UseInterruptConfig<any, TResult, TRenderInChat>" required>
Interrupt configuration.
<PropertyReference name="render" type="(props: InterruptRenderProps<any, TResult | null>) => React.ReactElement" required>
Render callback for interrupt UI. Called when an interrupt is available. The callback receives:
- `event` -- interrupt event (`{ name, value }`). `value` is `any`; type-narrow in your callback as needed.
- `result` -- inferred from `handler` return type, or `null`
- `resolve(response)` -- resumes the agent with `command.resume = response`
</PropertyReference>
<PropertyReference name="handler" type="(props: InterruptHandlerProps) => TResult | PromiseLike<TResult>">
Optional preprocessing callback. Runs before rendering and can return sync or async data that is exposed as `result` in `render`.
`TResult` is automatically inferred from the handler's return type.
If the handler throws/rejects, `result` is `null`.
</PropertyReference>
<PropertyReference name="enabled" type="(event: InterruptEvent) => boolean">
Optional filter. Return `false` to ignore matching interrupts for this hook instance.
</PropertyReference>
<PropertyReference name="agentId" type="string">
Optional agent id. Defaults to the currently configured chat agent.
</PropertyReference>
<PropertyReference name="renderInChat" type="boolean" default="true">
Controls where UI renders:
- `true` (default): publishes interrupt UI into `<CopilotChat>`
- `false`: returns interrupt element from the hook for manual placement
</PropertyReference>
</PropertyReference>
## Return Value
<PropertyReference name="element" type="Conditional by renderInChat">
Return type is inferred from `renderInChat`:
- `renderInChat: false` -> `React.ReactElement | null`
- `renderInChat: true` or omitted -> `void`
- dynamic boolean -> `React.ReactElement | null | void`
</PropertyReference>
## Usage
### In-chat interrupt UI (default)
```tsx
import { useInterrupt } from "@copilotkit/react-core/v2";
function ApprovalInterrupt() {
useInterrupt({
render: ({ event, resolve }) => (
<div className="p-3 border rounded">
<p>{event.value.question}</p>
<div className="mt-2 flex gap-2">
<button onClick={() => resolve({ approved: true })}>Approve</button>
<button onClick={() => resolve({ approved: false })}>Reject</button>
</div>
</div>
),
});
return null;
}
```
### Manual placement with async preprocessing
```tsx
import { useInterrupt } from "@copilotkit/react-core/v2";
function SidePanelInterrupt() {
const element = useInterrupt({
renderInChat: false,
enabled: (event) => event.value.startsWith("approval:"),
handler: async ({ event }) => ({ label: event.value.toUpperCase() }),
render: ({ event, result, resolve }) => (
<aside className="rounded border p-3">
<div className="font-medium">{result?.label ?? ""}</div>
<div className="mt-2">{event.value}</div>
<button className="mt-2" onClick={() => resolve({ accepted: true })}>
Continue
</button>
</aside>
),
});
return <>{element}</>;
}
```
## Behavior
- Interrupts are collected from agent custom events named `on_interrupt`.
- Interrupt UI is surfaced when the run finalizes.
- Starting a new run clears pending interrupt state.
- `event.value` is `any` -- type-narrow in your callbacks as needed.
- `render.result` is inferred from `handler` return type and is always `TResult | null`.
- If `handler` throws or rejects, `result` is set to `null`.
## Related
- [`useHumanInTheLoop`](/reference/v2/hooks/useHumanInTheLoop) -- structured interactive tool workflows
- [`useFrontendTool`](/reference/v2/hooks/useFrontendTool) -- client-side tool registration
- [`useAgent`](/reference/v2/hooks/useAgent) -- access and subscribe to agent events
@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
.next/
/out/
# turbo
.turbo
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# lock files
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lockb
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+106
View File
@@ -0,0 +1,106 @@
# CopilotKit <> LangGraph Starter
This is a starter template for building AI agents using [LangGraph](https://www.langchain.com/langgraph) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated LangGraph agent to be built on top of.
This project is organized as a monorepo using [Turborepo](https://turbo.build) and [pnpm workspaces](https://pnpm.io/workspaces).
## Project Structure
```
.
├── apps/
│ ├── web/ # Next.js frontend application
│ └── agent/ # LangGraph agent
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
```
## Prerequisites
- Node.js 18+
- [pnpm](https://pnpm.io/installation) 9.15.0 or later
- OpenAI API Key (for the LangGraph agent)
## Getting Started
1. Install all dependencies (this installs everything for both apps):
```bash
pnpm install
```
2. Set up your OpenAI API key:
```bash
cd apps/agent
echo "OPENAI_API_KEY=your-openai-api-key-here" > .env
```
3. Start the development servers:
```bash
pnpm dev
```
This will start both the Next.js app (on port 3000) and the LangGraph agent (on port 8123) using Turborepo.
## Available Scripts
All scripts use Turborepo to run tasks across the monorepo:
- `pnpm dev` - Starts both the web app and agent servers in development mode
- `pnpm dev:studio` - Starts the web app and agent with LangGraph Studio UI
- `pnpm build` - Builds all apps for production
- `pnpm lint` - Runs linting across all apps
### Running Scripts for Individual Apps
You can also run scripts for individual apps using pnpm's filter flag:
```bash
# Run dev for just the web app
pnpm --filter web dev
# Run dev for just the agent
pnpm --filter agent dev
# Or navigate to the app directory
cd apps/web
pnpm dev
```
## Customization
The main UI component is in `apps/web/src/app/page.tsx`. You can:
- Modify the theme colors and styling
- Add new frontend actions
- Utilize shared-state
- Customize your user-interface for interacting with LangGraph
The LangGraph agent code is in `apps/agent/src/`.
## 📚 Documentation
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Learn more about LangGraph and its features
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
## Contributing
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Troubleshooting
### Agent Connection Issues
If you see "I'm having trouble connecting to my tools", make sure:
1. The LangGraph agent is running on port 8000
2. Your OpenAI API key is set correctly
3. Both servers started successfully
@@ -0,0 +1,9 @@
venv/
__pycache__/
*.pyc
.env
.vercel
# LangGraph API
.langgraph_api
node_modules/
@@ -0,0 +1,9 @@
{
"node_version": "20",
"dockerfile_lines": [],
"dependencies": ["."],
"graphs": {
"default": "./src/agent.ts:graph"
},
"env": ".env"
}
@@ -0,0 +1,25 @@
{
"name": "agent-langraph-interrupt",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "npx @langchain/langgraph-cli dev --port 8125 --no-browser"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/html-to-text": "^9.0.4",
"@types/node": "^22.19.11",
"typescript": "^5.9.3"
},
"dependencies": {
"@copilotkit/sdk-js": "^1.51.4",
"@langchain/core": "^1.1.26",
"@langchain/langgraph": "1.1.5",
"@langchain/langgraph-checkpoint": "1.0.0",
"@langchain/openai": "^1.2.8",
"zod": "^3.25.76"
}
}
@@ -0,0 +1,27 @@
{
"name": "agent-langraph-interrupt",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/agent/src",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter agent-langraph-interrupt dev"
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"command": "node -e \"console.log('Agent app has no separate build target yet.')\""
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "node -e \"console.log('Agent app has no lint target yet.')\""
}
}
},
"tags": []
}
@@ -0,0 +1,139 @@
/**
* This is the main entry point for the agent.
* It defines the workflow graph, state, tools, nodes and edges.
*/
import { z } from "zod";
import { RunnableConfig } from "@langchain/core/runnables";
import { tool } from "@langchain/core/tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { AIMessage, SystemMessage } from "@langchain/core/messages";
import {
interrupt,
MemorySaver,
START,
StateGraph,
} from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import {
convertActionsToDynamicStructuredTools,
CopilotKitStateAnnotation,
} from "@copilotkit/sdk-js/langgraph";
import { Annotation } from "@langchain/langgraph";
// 1. Define our agent state, which includes CopilotKit state to
// provide actions to the state.
const AgentStateAnnotation = Annotation.Root({
...CopilotKitStateAnnotation.spec, // CopilotKit state annotation already includes messages, as well as frontend tools
proverbs: Annotation<string[]>,
});
// 2. Define the type for our agent state
export type AgentState = typeof AgentStateAnnotation.State;
// 3. Define a simple tool to get the weather statically
const getWeather = tool(
(args) => {
return `The weather for ${args.location} is 70 degrees, clear skies, 45% humidity, 5 mph wind, and feels like 72 degrees.`;
},
{
name: "getWeather",
description: "Get the weather for a given location.",
schema: z.object({
location: z.string().describe("The location to get weather for"),
}),
},
);
// 4. Define a tool that triggers a human-in-the-loop interrupt
const deleteProverb = tool(
async (args) => {
const approval = interrupt({
action: "delete_proverb",
proverb: args.proverb,
message: `Are you sure you want to delete the proverb: "${args.proverb}"?`,
});
if (approval?.approved) {
return `Proverb "${args.proverb}" has been deleted.`;
}
return `Deletion of proverb "${args.proverb}" was cancelled by the user.`;
},
{
name: "deleteProverb",
description:
"Delete a proverb from the list. This will ask the user for confirmation before deleting.",
schema: z.object({
proverb: z.string().describe("The proverb to delete"),
}),
},
);
// 5. Put our tools into an array
const tools = [getWeather, deleteProverb];
// 5. Define the chat node, which will handle the chat logic
async function chat_node(state: AgentState, config: RunnableConfig) {
// 5.1 Define the model, lower temperature for deterministic responses
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
// 5.2 Bind the tools to the model, include CopilotKit actions. This allows
// the model to call tools that are defined in CopilotKit by the frontend.
const modelWithTools = model.bindTools!([
...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []),
...tools,
]);
// 5.3 Define the system message, which will be used to guide the model, in this case
// we also add in the language to use from the state.
const systemMessage = new SystemMessage({
content: `You are a helpful assistant. The current proverbs are ${JSON.stringify(state.proverbs)}. If a user asks to delete a proverb, call deleteProverb to trigger a human-in-the-loop interrupt for confirmation.`,
});
// 5.4 Invoke the model with the system message and the messages in the state
const response = await modelWithTools.invoke(
[systemMessage, ...state.messages],
config,
);
// 5.5 Return the response, which will be added to the state
return {
messages: response,
};
}
// 6. Define the function that determines whether to continue or not,
// this is used to determine the next node to run
function shouldContinue({ messages, copilotkit }: AgentState) {
// 6.1 Get the last message from the state
const lastMessage = messages[messages.length - 1] as AIMessage;
// 7.2 If the LLM makes a tool call, then we route to the "tools" node
if (lastMessage.tool_calls?.length) {
// Actions are the frontend tools coming from CopilotKit
const actions = copilotkit?.actions;
const toolCallName = lastMessage.tool_calls![0].name;
// 7.3 Only route to the tool node if the tool call is not a CopilotKit action
if (!actions || actions.every((action) => action.name !== toolCallName)) {
return "tool_node";
}
}
// 6.4 Otherwise, we stop (reply to the user) using the special "__end__" node
return "__end__";
}
// Define the workflow graph
const workflow = new StateGraph(AgentStateAnnotation)
.addNode("chat_node", chat_node)
.addNode("tool_node", new ToolNode(tools))
.addEdge(START, "chat_node")
.addEdge("tool_node", "chat_node")
.addConditionalEdges("chat_node", shouldContinue as any);
const memory = new MemorySaver();
export const graph = workflow.compile({
checkpointer: memory,
});
@@ -0,0 +1,110 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "Node16", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
"resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
"resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@copilotkit/runtime"],
};
export default nextConfig;
@@ -0,0 +1,32 @@
{
"name": "web-langraph-interrupt",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@copilotkit/react-core": "workspace:*",
"@copilotkit/react-ui": "workspace:*",
"@copilotkit/runtime": "workspace:*",
"next": "16.0.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"shiki": "^3.22.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.8",
"tailwindcss": "^4",
"typescript": "^5"
}
}
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1,27 @@
{
"name": "web-langraph-interrupt",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/web/src",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter web-langraph-interrupt dev"
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter web-langraph-interrupt build"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter web-langraph-interrupt lint"
}
}
},
"tags": []
}
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,36 @@
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest } from "next/server";
// 1. You can use any service adapter here for multi-agent support. We use
// the empty adapter since we're only using one agent.
const serviceAdapter = new ExperimentalEmptyAdapter();
const agent = new LangGraphAgent({
deploymentUrl:
process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8125",
graphId: "default",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
// 2. Create the CopilotRuntime instance and utilize the LangGraph AG-UI
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
default: agent,
starterAgent: agent,
},
});
// 3. Build a Next.js API route that handles the CopilotKit runtime requests.
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,24 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body,
html {
height: 100%;
}
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={"antialiased"}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="default">
{children}
</CopilotKit>
</body>
</html>
);
}
@@ -0,0 +1,243 @@
"use client";
import { useCoAgent, useCopilotAction } from "@copilotkit/react-core";
import { CopilotKitCSSProperties, CopilotSidebar } from "@copilotkit/react-ui";
import { useInterrupt } from "@copilotkit/react-core/v2";
import { useState } from "react";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
// 🪁 Frontend Actions: https://docs.copilotkit.ai/guides/frontend-actions
useCopilotAction({
name: "setThemeColor",
description: "Set the theme color of the page.",
parameters: [
{
name: "themeColor",
description: "The theme color to set. Make sure to pick nice colors.",
required: true,
},
],
handler({ themeColor }) {
setThemeColor(themeColor);
},
});
// 🪁 Interrupts: Handle human-in-the-loop confirmations from the agent
useInterrupt({
render: ({ event, resolve }) => {
const { message, proverb, action } = event.value as {
message: string;
proverb: string;
action: string;
};
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 my-2">
<p className="text-sm font-medium text-yellow-800 mb-1">
Confirmation Required
</p>
<p className="text-sm text-yellow-700 mb-3">{message}</p>
<div className="flex gap-2">
<button
onClick={() => resolve({ approved: true })}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
Yes, delete it
</button>
<button
onClick={() => resolve({ approved: false })}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
</div>
</div>
);
},
});
return (
<main
style={
{ "--copilot-kit-primary-color": themeColor } as CopilotKitCSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
labels={{
title: "Popup Assistant",
initial:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n- **Interrupts**: "Delete the first proverb" (will ask for confirmation)\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
/>
</main>
);
}
// State of the agent, make sure this aligns with your agent's state.
type AgentState = {
proverbs: string[];
};
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
const { state, setState } = useCoAgent<AgentState>({
name: "default",
initialState: {
proverbs: [
"CopilotKit may be new, but its the best thing since sliced bread.",
],
},
});
// 🪁 Frontend Actions: https://docs.copilotkit.ai/coagents/frontend-actions
useCopilotAction(
{
name: "addProverb",
description: "Add a proverb to the list.",
parameters: [
{
name: "proverb",
description: "The proverb to add. Make it witty, short and concise.",
required: true,
},
],
handler: ({ proverb }) => {
setState((prevState) => ({
...prevState,
proverbs: [...(prevState?.proverbs || []), proverb],
}));
},
},
[setState],
);
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
useCopilotAction({
name: "getWeather",
description: "Get the weather for a given location.",
available: "disabled",
parameters: [{ name: "location", type: "string", required: true }],
render: ({ args }) => {
return <WeatherCard location={args.location} themeColor={themeColor} />;
},
});
return (
<div
style={{ backgroundColor: themeColor }}
className="h-screen w-screen flex justify-center items-center flex-col transition-colors duration-300"
>
<div className="bg-white/20 backdrop-blur-md p-8 rounded-2xl shadow-xl max-w-2xl w-full">
<h1 className="text-4xl font-bold text-white mb-2 text-center">
Proverbs
</h1>
<p className="text-gray-200 text-center italic mb-6">
This is a demonstrative page, but it could be anything you want! 🪁
</p>
<hr className="border-white/20 my-6" />
<div className="flex flex-col gap-3">
{state.proverbs?.map((proverb, index) => (
<div
key={index}
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
>
<p className="pr-8">{proverb}</p>
<button
onClick={() =>
setState({
...state,
proverbs: state.proverbs?.filter((_, i) => i !== index),
})
}
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity
bg-red-500 hover:bg-red-600 text-white rounded-full h-6 w-6 flex items-center justify-center"
>
</button>
</div>
))}
</div>
{state.proverbs?.length === 0 && (
<p className="text-center text-white/80 italic my-8">
No proverbs yet. Ask the assistant to add some!
</p>
)}
</div>
</div>
);
}
// Simple sun icon for the weather card
function SunIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-14 h-14 text-yellow-200"
>
<circle cx="12" cy="12" r="5" />
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
strokeWidth="2"
stroke="currentColor"
/>
</svg>
);
}
// Weather card component where the location and themeColor are based on what the agent
// sets via tool calls.
function WeatherCard({
location,
themeColor,
}: {
location?: string;
themeColor: string;
}) {
return (
<div
style={{ backgroundColor: themeColor }}
className="rounded-xl shadow-xl mt-6 mb-4 max-w-md w-full"
>
<div className="bg-white/20 p-4 w-full">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-white capitalize">
{location}
</h3>
<p className="text-white">Current Weather</p>
</div>
<SunIcon />
</div>
<div className="mt-4 flex items-end justify-between">
<div className="text-3xl font-bold text-white">70°</div>
<div className="text-sm text-white">Clear skies</div>
</div>
<div className="mt-4 pt-4 border-t border-white">
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-white text-xs">Humidity</p>
<p className="text-white font-medium">45%</p>
</div>
<div>
<p className="text-white text-xs">Wind</p>
<p className="text-white font-medium">5 mph</p>
</div>
<div>
<p className="text-white text-xs">Feels Like</p>
<p className="text-white font-medium">72°</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,16 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = [
...nextCoreWebVitals,
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
@@ -0,0 +1,27 @@
{
"name": "langgraph-js-starter",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/*"
],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint"
},
"devDependencies": {
"@langchain/langgraph-cli": "^1.0.4",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.8",
"turbo": "^2.3.3"
},
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=18.0.0"
},
"overrides": {
"@langchain/core": "^1.0.1",
"@langchain/langgraph": "1.0.2"
}
}
-3
View File
@@ -106,9 +106,6 @@
"prismjs@<=1.30.0": "1.30.0",
"pino@<=10.1.1": "10.1.1"
},
"publicHoistPattern": [
"@ag-ui/*"
],
"patchedDependencies": {
"@changesets/assemble-release-plan@6.0.9": "patches/@changesets__assemble-release-plan@6.0.9.patch"
}
@@ -49,6 +49,8 @@ vi.mock("@copilotkitnext/react", () => ({
clearSuggestions: vi.fn(),
addSuggestionsConfig: vi.fn(),
reloadSuggestions: vi.fn(),
interruptElement: null,
subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
},
})),
useCopilotChatConfiguration: vi.fn(() => ({ agentId: "test-agent" })),
@@ -67,10 +69,6 @@ vi.mock("../../components/error-boundary/error-utils", () => ({
useAsyncCallback: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("../use-langgraph-interrupt-render", () => ({
useLangGraphInterruptRender: vi.fn(() => null),
}));
vi.mock("../use-lazy-tool-renderer", () => ({
useLazyToolRenderer: vi.fn(() => () => null),
}));
@@ -20,7 +20,6 @@ export { useCoAgent, type HintFunction } from "./use-coagent";
export { useCopilotRuntimeClient } from "./use-copilot-runtime-client";
export { useCopilotAuthenticatedAction_c } from "./use-copilot-authenticated-action";
export { useLangGraphInterrupt } from "./use-langgraph-interrupt";
export { useLangGraphInterruptRender } from "./use-langgraph-interrupt-render";
export { useCopilotAdditionalInstructions } from "./use-copilot-additional-instructions";
export type { Tree, TreeNode } from "./use-tree";
export { useFrontendTool } from "./use-frontend-tool";
@@ -1,4 +1,4 @@
import {
import React, {
useRef,
useEffect,
useCallback,
@@ -14,7 +14,6 @@ import {
gqlToAGUI,
Message as DeprecatedGqlMessage,
} from "@copilotkit/runtime-client-gql";
import { useLangGraphInterruptRender } from "./use-langgraph-interrupt-render";
import {
useAgent,
useCopilotChatConfiguration,
@@ -363,7 +362,19 @@ export function useCopilotChatInternal({
onInProgress?.(Boolean(agent?.isRunning));
}, [agent?.isRunning, onInProgress]);
const interrupt = useLangGraphInterruptRender(agent);
// Subscribe to copilotkit.interruptElement so the v1 return type stays
// reactive. The element is published by useInterrupt (v2) when user code
// calls useLangGraphInterrupt({ render, ... }).
const [interrupt, setInterrupt] = useState<React.ReactElement | null>(null);
useEffect(() => {
setInterrupt(copilotkit.interruptElement);
const subscription = copilotkit.subscribe({
onInterruptElementChanged: ({ interruptElement }) => {
setInterrupt(interruptElement);
},
});
return () => subscription.unsubscribe();
}, [copilotkit]);
const reset = () => {
agent?.setMessages([]);
@@ -1,147 +0,0 @@
import { useCopilotContext } from "../context";
import React, { useCallback, useEffect, useMemo } from "react";
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
import { MetaEventName } from "@copilotkit/runtime-client-gql";
import { dataToUUID, parseJson } from "@copilotkit/shared";
import { useAgentNodeName } from "./use-agent-nodename";
import { useCopilotChatConfiguration } from "@copilotkitnext/react";
type InterruptProps = {
event: any;
result: any;
render: (props: {
event: any;
result: any;
resolve: (response: string) => void;
}) => string | React.ReactElement;
resolve: (response: string) => void;
};
const InterruptRenderer: React.FC<InterruptProps> = ({
event,
result,
render,
resolve,
}) => {
return render({ event, result, resolve });
};
export function useLangGraphInterruptRender(
agent: AbstractAgent,
): string | React.ReactElement | null {
const {
interruptActions,
agentSession,
threadId,
interruptEventQueue,
addInterruptEvent,
resolveInterruptEvent,
} = useCopilotContext();
const existingConfig = useCopilotChatConfiguration();
const resolvedAgentId = existingConfig?.agentId ?? "default";
const nodeName = useAgentNodeName(resolvedAgentId);
useEffect(() => {
if (!agent) return;
let localInterrupt: any = null;
const subscriber: AgentSubscriber = {
onCustomEvent: ({ event }) => {
if (event.name === "on_interrupt") {
const eventData = {
name: MetaEventName.LangGraphInterruptEvent,
type: event.type,
value: parseJson(event.value, event.value),
};
const eventId = dataToUUID(eventData, "interruptEvents");
localInterrupt = {
eventId,
threadId,
event: eventData,
};
}
},
onRunStartedEvent: () => {
localInterrupt = null;
},
onRunFinalized: () => {
if (localInterrupt) {
addInterruptEvent(localInterrupt);
localInterrupt = null;
}
},
};
const { unsubscribe } = agent.subscribe(subscriber);
return () => {
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent, threadId]);
const handleResolve = useCallback(
(eventId: string, response?: string) => {
agent?.runAgent({
forwardedProps: {
command: {
resume: response,
},
},
});
resolveInterruptEvent(threadId, eventId, response ?? "");
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[agent, threadId],
);
return useMemo(() => {
// Get the queue for this thread and find the first unresponded event
const eventQueue = interruptEventQueue[threadId] || [];
const currentQueuedEvent = eventQueue.find((qe) => !qe.event.response);
if (!currentQueuedEvent || !agentSession) return null;
// Find the first matching action from all registered actions
const allActions = Object.values(interruptActions);
const matchingAction = allActions.find((action) => {
if (!action.enabled) return true; // No filter = match all
return action.enabled({
eventValue: currentQueuedEvent.event.value,
agentMetadata: {
...agentSession,
nodeName,
},
});
});
if (!matchingAction) return null;
const { render, handler } = matchingAction;
const resolveInterrupt = (response: string) => {
handleResolve(currentQueuedEvent.eventId, response);
};
let result = null;
if (handler) {
result = handler({
event: currentQueuedEvent.event,
resolve: resolveInterrupt,
});
}
if (!render) return null;
return React.createElement(InterruptRenderer, {
event: currentQueuedEvent.event,
result,
render,
resolve: resolveInterrupt,
});
}, [
interruptActions,
interruptEventQueue,
threadId,
agentSession,
handleResolve,
]);
}
@@ -1,46 +1,105 @@
import { useContext, useEffect, useMemo } from "react";
import { CopilotContext } from "../context/copilot-context";
import React, { useCallback, useRef } from "react";
import { LangGraphInterruptRender } from "../types/interrupt-action";
import { useToast } from "../components/toast/toast-provider";
import { dataToUUID } from "@copilotkit/shared";
import {
useInterrupt,
useCopilotChatConfiguration,
} from "@copilotkitnext/react";
import type {
InterruptEvent,
InterruptRenderProps,
InterruptHandlerProps,
} from "@copilotkitnext/react";
import { MetaEventName } from "@copilotkit/runtime-client-gql";
import { parseJson } from "@copilotkit/shared";
import { useAgentNodeName } from "./use-agent-nodename";
import type { AgentSession } from "../context/copilot-context";
/**
* Transforms a v2 InterruptEvent into the v1 LangGraphInterruptEvent shape
* expected by existing useLangGraphInterrupt callbacks.
*/
function toV1Event<TEventValue>(event: InterruptEvent<TEventValue>) {
const value =
typeof event.value === "string"
? parseJson(event.value, event.value)
: event.value;
return {
name: MetaEventName.LangGraphInterruptEvent,
type: "MetaEvent" as const,
value,
};
}
export function useLangGraphInterrupt<TEventValue = any>(
action: Omit<LangGraphInterruptRender<TEventValue>, "id">,
dependencies?: any[],
_dependencies?: any[],
) {
const {
setInterruptAction,
removeInterruptAction,
interruptActions,
const actionRef = useRef(action);
// Update ref synchronously during render so it's always current
// when callbacks read from it (useEffect would be one tick late).
actionRef.current = action;
const existingConfig = useCopilotChatConfiguration();
const resolvedAgentId = existingConfig?.agentId ?? "default";
const threadId = existingConfig?.threadId;
const nodeName = useAgentNodeName(resolvedAgentId);
// Keep agentMetadata in a ref so stable callbacks always see current values.
const metadataRef = useRef<AgentSession>({
agentName: resolvedAgentId,
threadId,
} = useContext(CopilotContext);
const { addToast } = useToast();
const actionId = dataToUUID(action, "lgAction");
useEffect(() => {
if (!action) return;
// if (!action.enabled) {
// TODO: if there are any other actions registered, we need to warn the user that a current action without "enabled" might render for everything
// addToast({
// type: "warning",
// message: "An action is already registered for the interrupt event",
// });
// return;
// }
setInterruptAction({ ...action, id: actionId });
// Cleanup: remove action on unmount
return () => {
removeInterruptAction(actionId);
};
}, [
setInterruptAction,
removeInterruptAction,
nodeName,
});
metadataRef.current = {
agentName: resolvedAgentId,
threadId,
actionId,
...(dependencies || []),
]);
nodeName,
};
// Stable callback references that always read the latest action from the ref.
// This prevents useInterrupt's internal useMemo/useEffect from seeing new
// function identities on every render, which would cause an infinite loop.
const render = useCallback(
({ event, result, resolve }: InterruptRenderProps<TEventValue>) => {
const renderFn = actionRef.current.render;
if (!renderFn) return React.createElement(React.Fragment);
const rendered = renderFn({
event: toV1Event(event) as any,
result,
resolve: (r) => resolve(r),
});
if (typeof rendered === "string") {
return React.createElement(React.Fragment, null, rendered);
}
return rendered;
},
[],
);
// Handler always delegates to the ref — if no handler is set at call time,
// the optional chaining returns undefined which useInterrupt treats as null.
const handler = useCallback(
({ event, resolve }: InterruptHandlerProps<TEventValue>) => {
return actionRef.current.handler?.({
event: toV1Event(event) as any,
resolve: (r) => resolve(r),
});
},
[],
);
const enabled = useCallback((event: InterruptEvent<TEventValue>) => {
if (!actionRef.current.enabled) return true;
return actionRef.current.enabled({
eventValue: toV1Event(event).value,
agentMetadata: metadataRef.current,
});
}, []);
useInterrupt({
render,
handler,
enabled,
agentId: resolvedAgentId,
});
}
+6 -2
View File
@@ -14,6 +14,7 @@ import { FrontendTool } from "../types";
export interface CopilotKitCoreRunAgentParams {
agent: AbstractAgent;
forwardedProps?: Record<string, unknown>;
}
export interface CopilotKitCoreConnectAgentParams {
@@ -162,6 +163,7 @@ export class RunHandler {
*/
async runAgent({
agent,
forwardedProps,
}: CopilotKitCoreRunAgentParams): Promise<RunAgentResult> {
// Agent ID is guaranteed to be set by validateAndAssignAgentId
if (agent.agentId) {
@@ -179,8 +181,10 @@ export class RunHandler {
try {
const runAgentResult = await agent.runAgent(
{
forwardedProps: (this.core as unknown as CopilotKitCoreFriendsAccess)
.properties,
forwardedProps: {
...(this.core as unknown as CopilotKitCoreFriendsAccess).properties,
...forwardedProps,
},
tools: this.buildFrontendTools(agent.agentId),
context: Object.values(
(this.core as unknown as CopilotKitCoreFriendsAccess).context,
@@ -1,4 +1,4 @@
import React, { useEffect, useReducer } from "react";
import React, { useEffect, useReducer, useState } from "react";
import { WithSlots, renderSlot, isReactComponentType } from "@/lib/slots";
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
import CopilotChatUserMessage from "./CopilotChatUserMessage";
@@ -289,6 +289,7 @@ export type CopilotChatMessageViewProps = Omit<
isRunning: boolean;
messages: Message[];
messageElements: React.ReactElement[];
interruptElement: React.ReactElement | null;
}) => React.ReactElement;
};
@@ -321,6 +322,19 @@ export function CopilotChatMessageView({
return () => subscription.unsubscribe();
}, [config?.agentId, copilotkit, forceUpdate]);
// Subscribe to interrupt element changes for in-chat rendering.
const [interruptElement, setInterruptElement] =
useState<React.ReactElement | null>(null);
useEffect(() => {
setInterruptElement(copilotkit.interruptElement);
const subscription = copilotkit.subscribe({
onInterruptElementChanged: ({ interruptElement }) => {
setInterruptElement(interruptElement);
},
});
return () => subscription.unsubscribe();
}, [copilotkit]);
// Helper to get state snapshot for a message (used for memoization)
const getStateSnapshotForMessage = (messageId: string): unknown => {
if (!config) return undefined;
@@ -479,7 +493,7 @@ export function CopilotChatMessageView({
if (children) {
return (
<div data-copilotkit style={{ display: "contents" }}>
{children({ messageElements, messages, isRunning })}
{children({ messageElements, messages, isRunning, interruptElement })}
</div>
);
}
@@ -496,6 +510,7 @@ export function CopilotChatMessageView({
{...props}
>
{messageElements}
{interruptElement}
{showCursor && (
<div className="cpk:mt-2">
{renderSlot(cursor, CopilotChatMessageView.Cursor, {})}
@@ -0,0 +1,396 @@
import React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useInterrupt } from "../use-interrupt";
import { useCopilotKit } from "@/providers/CopilotKitProvider";
import { useAgent } from "../use-agent";
vi.mock("@/providers/CopilotKitProvider", () => ({
useCopilotKit: vi.fn(),
}));
vi.mock("../use-agent", () => ({
useAgent: vi.fn(),
}));
const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
const mockUseAgent = useAgent as ReturnType<typeof vi.fn>;
type SubscriptionHandlers = {
onCustomEvent?: (payload: {
event: { name: string; value: unknown };
}) => void;
onRunStartedEvent?: () => void;
onRunFinalized?: () => void;
onRunFailed?: () => void;
};
describe("useInterrupt", () => {
let runAgentMock: ReturnType<typeof vi.fn>;
let setInterruptElementMock: ReturnType<typeof vi.fn>;
let unsubscribeMock: ReturnType<typeof vi.fn>;
let subscribeMock: ReturnType<typeof vi.fn>;
let handlers: SubscriptionHandlers;
let mockAgent: Record<string, unknown>;
beforeEach(() => {
runAgentMock = vi.fn();
setInterruptElementMock = vi.fn();
unsubscribeMock = vi.fn();
handlers = {};
subscribeMock = vi.fn((nextHandlers: SubscriptionHandlers) => {
handlers = nextHandlers;
return { unsubscribe: unsubscribeMock };
});
mockAgent = {
subscribe: subscribeMock,
id: "test-agent",
};
mockUseCopilotKit.mockReturnValue({
copilotkit: {
runAgent: runAgentMock,
setInterruptElement: setInterruptElementMock,
},
});
mockUseAgent.mockReturnValue({ agent: mockAgent });
});
function Harness({
enabled,
handler,
renderInChat,
renderSpy,
}: {
enabled?: (event: { name: string; value: unknown }) => boolean;
handler?: (props: {
event: { name: string; value: unknown };
resolve: (response: unknown) => void;
}) => unknown;
renderInChat?: boolean;
renderSpy?: ReturnType<typeof vi.fn>;
}) {
const renderInterrupt = ({
event,
result,
resolve,
}: {
event: { name: string; value: unknown };
result: unknown;
resolve: (response: unknown) => void;
}) => {
renderSpy?.({ event, result, resolve });
return (
<button
data-testid="interrupt"
onClick={() => resolve({ approved: true, value: event.value })}
>
{String(result ?? "no-result")}:{String(event.value)}
</button>
);
};
if (renderInChat === false) {
return (
<ManualHarness
enabled={enabled}
handler={handler}
render={renderInterrupt}
/>
);
}
return (
<ChatHarness
enabled={enabled}
handler={handler}
render={renderInterrupt}
/>
);
}
function ManualHarness({
enabled,
handler,
render,
}: {
enabled?: (event: { name: string; value: unknown }) => boolean;
handler?: (props: {
event: { name: string; value: unknown };
resolve: (response: unknown) => void;
}) => unknown;
render: (props: {
event: { name: string; value: unknown };
result: unknown;
resolve: (response: unknown) => void;
}) => React.ReactElement;
}) {
const element = useInterrupt({
enabled,
handler,
renderInChat: false,
render,
});
return <div data-testid="manual-container">{element}</div>;
}
function ChatHarness({
enabled,
handler,
render,
}: {
enabled?: (event: { name: string; value: unknown }) => boolean;
handler?: (props: {
event: { name: string; value: unknown };
resolve: (response: unknown) => void;
}) => unknown;
render: (props: {
event: { name: string; value: unknown };
result: unknown;
resolve: (response: unknown) => void;
}) => React.ReactElement;
}) {
useInterrupt({
enabled,
handler,
render,
});
return <div data-testid="manual-container" />;
}
function emitInterrupt(value: unknown) {
act(() => {
handlers.onCustomEvent?.({
event: { name: "on_interrupt", value },
});
handlers.onRunFinalized?.();
});
}
it("subscribes on mount and unsubscribes on unmount", () => {
const { unmount } = render(<Harness renderInChat={false} />);
expect(subscribeMock).toHaveBeenCalledTimes(1);
expect(handlers.onCustomEvent).toBeTypeOf("function");
expect(handlers.onRunStartedEvent).toBeTypeOf("function");
expect(handlers.onRunFinalized).toBeTypeOf("function");
expect(handlers.onRunFailed).toBeTypeOf("function");
unmount();
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
});
it("ignores non-interrupt custom events", () => {
render(<Harness renderInChat={false} />);
act(() => {
handlers.onCustomEvent?.({
event: { name: "not_interrupt", value: "x" },
});
handlers.onRunFinalized?.();
});
expect(screen.queryByTestId("interrupt")).toBeNull();
});
it("renders interrupt only after run finalized", () => {
render(<Harness renderInChat={false} />);
act(() => {
handlers.onCustomEvent?.({
event: { name: "on_interrupt", value: "pending" },
});
});
expect(screen.queryByTestId("interrupt")).toBeNull();
act(() => {
handlers.onRunFinalized?.();
});
expect(screen.getByTestId("interrupt").textContent).toContain("pending");
});
it("clears pending interrupt on run start", () => {
render(<Harness renderInChat={false} />);
emitInterrupt("first");
expect(screen.getByTestId("interrupt").textContent).toContain("first");
act(() => {
handlers.onRunStartedEvent?.();
});
expect(screen.queryByTestId("interrupt")).toBeNull();
});
it("resolve clears UI and resumes agent with response payload", () => {
render(<Harness renderInChat={false} />);
emitInterrupt("approve-me");
act(() => {
screen.getByTestId("interrupt").click();
});
expect(screen.queryByTestId("interrupt")).toBeNull();
expect(runAgentMock).toHaveBeenCalledTimes(1);
expect(runAgentMock).toHaveBeenCalledWith({
agent: mockAgent,
forwardedProps: {
command: {
resume: { approved: true, value: "approve-me" },
},
},
});
});
it("does not render and does not run handler when enabled returns false", () => {
const handler = vi.fn(() => "should-not-run");
render(
<Harness renderInChat={false} enabled={() => false} handler={handler} />,
);
emitInterrupt("blocked");
expect(screen.queryByTestId("interrupt")).toBeNull();
expect(handler).not.toHaveBeenCalled();
});
it("renders with null result when no handler is provided", () => {
render(<Harness renderInChat={false} />);
emitInterrupt("no-handler");
expect(screen.getByTestId("interrupt").textContent).toContain(
"no-result:no-handler",
);
});
it("uses sync handler result in render", () => {
render(
<Harness
renderInChat={false}
handler={({ event }) => `handled:${String(event.value)}`}
/>,
);
emitInterrupt("sync");
expect(screen.getByTestId("interrupt").textContent).toContain(
"handled:sync",
);
});
it("uses async handler resolved value in render", async () => {
render(
<Harness
renderInChat={false}
handler={({ event }) => Promise.resolve(`async:${String(event.value)}`)}
/>,
);
emitInterrupt("value");
await waitFor(() => {
expect(screen.getByTestId("interrupt").textContent).toContain(
"async:value",
);
});
});
it("falls back to null result when async handler rejects", async () => {
render(
<Harness
renderInChat={false}
handler={() => Promise.reject(new Error("boom"))}
/>,
);
emitInterrupt("reject");
await waitFor(() => {
expect(screen.getByTestId("interrupt").textContent).toContain(
"no-result:reject",
);
});
});
it("accepts thenable handler results (non-native Promise)", async () => {
const thenable = {
then: (resolve: (value: string) => void) => {
resolve("thenable-ok");
return { catch: () => undefined };
},
};
render(<Harness renderInChat={false} handler={() => thenable} />);
emitInterrupt("thenable");
await waitFor(() => {
expect(screen.getByTestId("interrupt").textContent).toContain(
"thenable-ok:thenable",
);
});
});
it("publishes interrupt element to chat by default and clears on unmount", async () => {
const renderSpy = vi.fn();
const { unmount } = render(<Harness renderSpy={renderSpy} />);
emitInterrupt("chat");
await waitFor(() => {
expect(renderSpy).toHaveBeenCalled();
expect(setInterruptElementMock).toHaveBeenCalled();
});
const latestCallArg = setInterruptElementMock.mock.calls.at(-1)?.[0];
expect(React.isValidElement(latestCallArg)).toBe(true);
unmount();
expect(setInterruptElementMock.mock.calls.at(-1)?.[0]).toBeNull();
});
it("does not publish to chat and returns manual element when renderInChat is false", () => {
render(<Harness renderInChat={false} />);
emitInterrupt("manual");
expect(screen.getByTestId("interrupt").textContent).toContain("manual");
expect(setInterruptElementMock).not.toHaveBeenCalled();
});
it("discards local interrupt when run fails before finalize", () => {
render(<Harness renderInChat={false} />);
act(() => {
handlers.onCustomEvent?.({
event: { name: "on_interrupt", value: "lost" },
});
handlers.onRunFailed?.();
handlers.onRunFinalized?.();
});
expect(screen.queryByTestId("interrupt")).toBeNull();
});
it("keeps the latest interrupt when multiple interrupts arrive within one run", () => {
render(<Harness renderInChat={false} />);
act(() => {
handlers.onCustomEvent?.({
event: { name: "on_interrupt", value: "first" },
});
handlers.onCustomEvent?.({
event: { name: "on_interrupt", value: "second" },
});
handlers.onRunFinalized?.();
});
expect(screen.getByTestId("interrupt").textContent).toContain("second");
});
});
+2
View File
@@ -12,3 +12,5 @@ export { useAgentContext } from "./use-agent-context";
export type { AgentContextInput, JsonSerializable } from "./use-agent-context";
export { useSuggestions } from "./use-suggestions";
export { useConfigureSuggestions } from "./use-configure-suggestions";
export { useInterrupt } from "./use-interrupt";
export type { UseInterruptConfig } from "./use-interrupt";
@@ -0,0 +1,292 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useCopilotKit } from "@/providers/CopilotKitProvider";
import { useAgent } from "./use-agent";
import type {
InterruptEvent,
InterruptRenderProps,
InterruptHandlerProps,
} from "../types/interrupt";
export type { InterruptEvent, InterruptRenderProps, InterruptHandlerProps };
const INTERRUPT_EVENT_NAME = "on_interrupt";
type InterruptHandlerFn<TValue, TResult> = (
props: InterruptHandlerProps<TValue>,
) => TResult | PromiseLike<TResult>;
type InterruptResultFromHandler<THandler> = THandler extends (
...args: never[]
) => infer TResult
? TResult extends PromiseLike<infer TResolved>
? TResolved | null
: TResult | null
: null;
type InterruptResult<TValue, TResult> = InterruptResultFromHandler<
InterruptHandlerFn<TValue, TResult>
>;
type InterruptRenderInChat = boolean | undefined;
type UseInterruptReturn<TRenderInChat extends InterruptRenderInChat> =
TRenderInChat extends false
? React.ReactElement | null
: TRenderInChat extends true | undefined
? void
: React.ReactElement | null | void;
export function isPromiseLike<TValue>(
value: TValue | PromiseLike<TValue>,
): value is PromiseLike<TValue> {
return (
(typeof value === "object" || typeof value === "function") &&
value !== null &&
typeof Reflect.get(value, "then") === "function"
);
}
/**
* Configuration options for `useInterrupt`.
*/
interface UseInterruptConfigBase<TValue = unknown, TResult = never> {
/**
* Render function for the interrupt UI.
*
* This is called once an interrupt is finalized and accepted by `enabled` (if provided).
* Use `resolve` from render props to resume the agent run with user input.
*/
render: (
props: InterruptRenderProps<TValue, InterruptResult<TValue, TResult>>,
) => React.ReactElement;
/**
* Optional pre-render handler invoked when an interrupt is received.
*
* Return either a sync value or an async value to pass into `render` as `result`.
* Rejecting/throwing falls back to `result = null`.
*/
handler?: InterruptHandlerFn<TValue, TResult>;
/**
* Optional predicate to filter which interrupts should be handled by this hook.
* Return `false` to ignore an interrupt.
*/
enabled?: (event: InterruptEvent<TValue>) => boolean;
/** Optional agent id. Defaults to the current configured chat agent. */
agentId?: string;
}
export interface UseInterruptInChatConfig<
TValue = unknown,
TResult = never,
> extends UseInterruptConfigBase<TValue, TResult> {
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
renderInChat?: true;
}
export interface UseInterruptExternalConfig<
TValue = unknown,
TResult = never,
> extends UseInterruptConfigBase<TValue, TResult> {
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
renderInChat: false;
}
export interface UseInterruptDynamicConfig<
TValue = unknown,
TResult = never,
> extends UseInterruptConfigBase<TValue, TResult> {
/** Dynamic boolean mode. When non-literal, return type is a union. */
renderInChat: boolean;
}
export type UseInterruptConfig<
TValue = unknown,
TResult = never,
TRenderInChat extends InterruptRenderInChat = undefined,
> = UseInterruptConfigBase<TValue, TResult> & {
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
renderInChat?: TRenderInChat;
};
/**
* Handles agent interrupts (`on_interrupt`) with optional filtering, preprocessing, and resume behavior.
*
* The hook listens to custom events on the active agent, stores interrupt payloads per run,
* and surfaces a render callback once the run finalizes. Call `resolve` from your UI to resume
* execution with user-provided data.
*
* - `renderInChat: true` (default): the element is published into `<CopilotChat>` and this hook returns `void`.
* - `renderInChat: false`: the hook returns the interrupt element so you can place it anywhere in your component tree.
*
* `event.value` is typed as `any` since the interrupt payload shape depends on your agent.
* Type-narrow it in your callbacks (e.g. `handler`, `enabled`, `render`) as needed.
*
* @typeParam TResult - Inferred from `handler` return type. Exposed as `result` in `render`.
* @param config - Interrupt configuration (renderer, optional handler/filter, and render mode).
* @returns When `renderInChat` is `false`, returns the interrupt element (or `null` when idle).
* Otherwise returns `void` and publishes the element into chat. In `render`, `result` is always
* either the handler's resolved return value or `null` (including when no handler is provided,
* when filtering skips the interrupt, or when handler execution fails).
*
* @example
* ```tsx
* import { useInterrupt } from "@copilotkitnext/react";
*
* function InterruptUI() {
* useInterrupt({
* render: ({ event, resolve }) => (
* <div>
* <p>{event.value.question}</p>
* <button onClick={() => resolve({ approved: true })}>Approve</button>
* <button onClick={() => resolve({ approved: false })}>Reject</button>
* </div>
* ),
* });
*
* return null;
* }
* ```
*
* @example
* ```tsx
* import { useInterrupt } from "@copilotkitnext/react";
*
* function CustomPanel() {
* const interruptElement = useInterrupt({
* renderInChat: false,
* enabled: (event) => event.value.startsWith("approval:"),
* handler: async ({ event }) => ({ label: event.value.toUpperCase() }),
* render: ({ event, result, resolve }) => (
* <aside>
* <strong>{result?.label ?? ""}</strong>
* <button onClick={() => resolve({ value: event.value })}>Continue</button>
* </aside>
* ),
* });
*
* return <>{interruptElement}</>;
* }
* ```
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export function useInterrupt<
TResult = never,
TRenderInChat extends InterruptRenderInChat = undefined,
>(
config: UseInterruptConfig<any, TResult, TRenderInChat>,
): UseInterruptReturn<TRenderInChat> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const { copilotkit } = useCopilotKit();
const { agent } = useAgent({ agentId: config.agentId });
const [pendingEvent, setPendingEvent] = useState<InterruptEvent | null>(null);
const [handlerResult, setHandlerResult] =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useState<InterruptResult<any, TResult>>(null);
useEffect(() => {
let localInterrupt: InterruptEvent | null = null;
const subscription = agent.subscribe({
onCustomEvent: ({ event }) => {
if (event.name === INTERRUPT_EVENT_NAME) {
localInterrupt = { name: event.name, value: event.value };
}
},
onRunStartedEvent: () => {
localInterrupt = null;
setPendingEvent(null);
},
onRunFinalized: () => {
if (localInterrupt) {
setPendingEvent(localInterrupt);
localInterrupt = null;
}
},
onRunFailed: () => {
localInterrupt = null;
},
});
return () => subscription.unsubscribe();
}, [agent]);
const resolve = useCallback(
(response: unknown) => {
setPendingEvent(null);
copilotkit.runAgent({
agent,
forwardedProps: { command: { resume: response } },
});
},
[agent, copilotkit],
);
useEffect(() => {
// No interrupt to process — reset any stale handler result from a previous interrupt
if (!pendingEvent) {
setHandlerResult(null);
return;
}
// Interrupt exists but the consumer's filter rejects it — treat as no-op
if (config.enabled && !config.enabled(pendingEvent)) {
setHandlerResult(null);
return;
}
const handler = config.handler;
// No handler provided — skip straight to rendering with a null result
if (!handler) {
setHandlerResult(null);
return;
}
let cancelled = false;
const maybePromise = handler({
event: pendingEvent,
resolve,
});
// If the handler returns a promise/thenable, wait for resolution before setting result.
if (isPromiseLike(maybePromise)) {
Promise.resolve(maybePromise)
.then((resolved) => {
if (!cancelled) setHandlerResult(resolved);
})
.catch(() => {
if (!cancelled) setHandlerResult(null);
});
} else {
setHandlerResult(maybePromise);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingEvent, config.enabled, config.handler, resolve]);
const element = useMemo(() => {
if (!pendingEvent) return null;
if (config.enabled && !config.enabled(pendingEvent)) return null;
return config.render({
event: pendingEvent,
result: handlerResult,
resolve,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingEvent, handlerResult, config.enabled, config.render, resolve]);
// Publish to core for in-chat rendering
useEffect(() => {
if (config.renderInChat === false) return;
copilotkit.setInterruptElement(element);
return () => copilotkit.setInterruptElement(null);
}, [element, config.renderInChat, copilotkit]);
// Only return element when rendering outside chat
if (config.renderInChat === false) {
return element as UseInterruptReturn<TRenderInChat>;
}
return undefined as UseInterruptReturn<TRenderInChat>;
}
+23 -5
View File
@@ -1,8 +1,6 @@
import type {
ReactActivityMessageRenderer,
ReactToolCallRenderer,
} from "@/types";
import type { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer";
import React from "react";
import { ReactActivityMessageRenderer, ReactToolCallRenderer } from "@/types";
import { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer";
import {
CopilotKitCore,
type CopilotKitCoreConfig,
@@ -24,12 +22,17 @@ export interface CopilotKitCoreReactSubscriber extends CopilotKitCoreSubscriber
copilotkit: CopilotKitCore;
renderToolCalls: ReactToolCallRenderer<any>[];
}) => void | Promise<void>;
onInterruptElementChanged?: (event: {
copilotkit: CopilotKitCore;
interruptElement: React.ReactElement | null;
}) => void | Promise<void>;
}
export class CopilotKitCoreReact extends CopilotKitCore {
private _renderToolCalls: ReactToolCallRenderer<any>[] = [];
private _renderCustomMessages: ReactCustomMessageRenderer[] = [];
private _renderActivityMessages: ReactActivityMessageRenderer<any>[] = [];
private _interruptElement: React.ReactElement | null = null;
constructor(config: CopilotKitCoreReactConfig) {
super(config);
@@ -65,6 +68,21 @@ export class CopilotKitCoreReact extends CopilotKitCore {
}, "Subscriber onRenderToolCallsChanged error:");
}
get interruptElement(): React.ReactElement | null {
return this._interruptElement;
}
setInterruptElement(element: React.ReactElement | null): void {
this._interruptElement = element;
void this.notifySubscribers((subscriber) => {
const reactSubscriber = subscriber as CopilotKitCoreReactSubscriber;
reactSubscriber.onInterruptElementChanged?.({
copilotkit: this,
interruptElement: this._interruptElement,
});
}, "Subscriber onInterruptElementChanged error:");
}
// Override to accept React-specific subscriber type
subscribe(
subscriber: CopilotKitCoreReactSubscriber,
+1
View File
@@ -4,3 +4,4 @@ export * from "./react-custom-message-renderer";
export * from "./frontend-tool";
export * from "./human-in-the-loop";
export * from "./defineToolCallRenderer";
export * from "./interrupt";
+15
View File
@@ -0,0 +1,15 @@
export interface InterruptEvent<TValue = unknown> {
name: string;
value: TValue;
}
export interface InterruptHandlerProps<TValue = unknown> {
event: InterruptEvent<TValue>;
resolve: (response: unknown) => void;
}
export interface InterruptRenderProps<TValue = unknown, TResult = unknown> {
event: InterruptEvent<TValue>;
result: TResult;
resolve: (response: unknown) => void;
}
+2894 -368
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ packages:
- "packages/v2/*"
- "examples/v1/*"
- "examples/v2/*"
- "examples/v2/*/apps/*"
- "examples/v2/react/*"
- "examples/v2/angular/*"
- "!examples/v1/_legacy"
+21 -4
View File
@@ -23,8 +23,8 @@ Creates and owns a `CopilotKitCore` instance that manages agents, frontend tools
handler and a tool call renderer.
The provider merges the above into a `CopilotKitCore` instance, keeps render definitions in sync with React state, and
exposes them through context. Frontend tools added through hooks (`useFrontendTool`, `useHumanInTheLoop`) are
automatically registered and cleaned up.
exposes them through context. Frontend tools added through hooks (`useFrontendTool`, `useHumanInTheLoop`,
`useInterrupt`) are automatically registered and cleaned up.
Render your entire Copilot-enabled tree inside this provider:
@@ -100,6 +100,23 @@ Wraps `useFrontendTool` for interactive tools that pause agent execution. Expect
The render component receives consistent shape based on the tool call status so you can drive bespoke UI/UX for human
confirmations.
### `useInterrupt(config)`
Subscribes to agent custom events named `on_interrupt` and surfaces interrupt UI once a run finalizes. The hook accepts
`UseInterruptConfig` and exposes two generics -- `TResult` (inferred from `handler` return type) and `TRenderInChat`
(inferred from `renderInChat`). `event.value` is typed as `any` since the interrupt payload shape depends on the agent;
type-narrow it in your callbacks as needed.
- `render(props)` (required) renders interrupt UI with `{ event, result, resolve }`.
- `handler(props)?` optionally preprocesses the interrupt and can return sync or async data passed to `result`. The
return type is automatically inferred and exposed as `result` in `render`.
- `enabled(event)?` optionally filters interrupts handled by this instance.
- `renderInChat?: boolean` controls rendering mode:
- `true` (default): publishes interrupt UI inside `CopilotChat`
- `false`: returns the interrupt element for manual placement
Call `resolve(response)` from the rendered UI to resume the agent with `command.resume = response`.
### `useRenderToolCall()`
Returns a renderer function that takes `{ toolCall, toolMessage, isLoading }` and returns a React element or `null`. The
@@ -252,5 +269,5 @@ at the app boundary (after your Tailwind base) to ensure animations, prose styli
inputs.
3. Render `CopilotChat` for an out-of-the-box experience, or compose `CopilotChatView`, `CopilotChatMessageView`, and
`CopilotChatInput` manually for deeper customization.
4. Register custom tools with `useFrontendTool` or `useHumanInTheLoop`, and render tool call output with
`useRenderToolCall` or `CopilotChatToolCallsView`.
4. Register custom tools with `useFrontendTool` or `useHumanInTheLoop`, handle interrupts with `useInterrupt`, and
render tool call output with `useRenderToolCall` or `CopilotChatToolCallsView`.