>
),
});
// Submit a message
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Execute no followup" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Execute no followup")).toBeDefined();
});
const messageId = testId("msg");
const toolCallId = testId("tc");
// Start run and emit tool call
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId,
toolCallName: "noFollowUpTool",
parentMessageId: messageId,
delta: '{"action":"stop-after-this"}',
}),
);
// Tool should render
await waitFor(() => {
expect(screen.getByTestId("no-followup-tool")).toBeDefined();
expect(screen.getByTestId("tool-action").textContent).toBe(
"stop-after-this",
);
});
// The agent should NOT continue after this tool call
// We can verify this by NOT emitting more events and checking the UI state
// In a real scenario, the agent would stop sending events
agent.emit(runFinishedEvent());
agent.complete();
// Verify execution stopped (no further messages)
// The chat should only have the user message and tool call, no follow-up
const messages = screen.queryAllByRole("article");
expect(messages.length).toBeLessThanOrEqual(2); // User message + tool response
});
it("continues agent execution when followUp is true or undefined", async () => {
const agent = new MockStepwiseAgent();
const ContinueFollowUpTool: React.FC = () => {
const tool: ReactFrontendTool<{ action: string }> = {
name: "continueFollowUpTool",
parameters: z.object({ action: z.string() }),
// followUp is undefined (default) - should continue execution
render: ({ args }) => (
>
);
};
renderWithCopilotKit({ agent, children: });
// Tool should be mounted initially
expect(screen.getByTestId("tool-mounted")).toBeDefined();
// Run 1: submit a message to trigger agent run with "first call"
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Trigger 1" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
// The tool should render and handler should have produced a result
await waitFor(() => {
const toolRender = screen.getByTestId("temporary-tool");
expect(toolRender.textContent).toContain("first call");
expect(toolRender.textContent).toContain("HANDLED FIRST CALL");
expect(handlerCalls).toBe(1);
});
// Unmount the tool component (removes handler but keeps renderer via hook policy)
fireEvent.click(screen.getByTestId("toggle-button"));
await waitFor(() => {
expect(screen.queryByTestId("tool-mounted")).toBeNull();
});
// Run 2: trigger agent again with "second call"
fireEvent.change(input, { target: { value: "Trigger 2" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
// The renderer should still render with new args, but no handler result should be produced
await waitFor(() => {
const toolRender = screen.getAllByTestId("temporary-tool");
// There will be two renders in the chat history; check the last one
const last = toolRender[toolRender.length - 1];
expect(last?.textContent).toContain("second call");
// The handler should not have been called a second time since tool was removed
expect(handlerCalls).toBe(1);
});
});
});
describe("Override behavior", () => {
it("should use latest registration when same tool name is registered multiple times", async () => {
const agent = new MockStepwiseAgent();
// First component with initial tool definition
const FirstToolComponent: React.FC = () => {
const tool: ReactFrontendTool<{ text: string }> = {
name: "overridableTool",
parameters: z.object({ text: z.string() }),
render: ({ name, args }) => (
>
),
});
// Submit message to trigger tools
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Test agent scoping" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Test agent scoping")).toBeDefined();
});
const messageId = testId("msg");
const toolCallId = testId("tc");
// Call "testTool" - multiple tools have this name but only the one
// scoped to "default" agent should execute its handler
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId,
toolCallName: "testTool",
parentMessageId: messageId,
delta: '{"message":"test message"}',
}),
);
agent.emit(runFinishedEvent());
// Wait for tool to render - the correct renderer should be used
await waitFor(() => {
// The default agent tool should render (it's scoped to our agent)
const defaultTool = screen.queryByTestId("default-agent-tool");
expect(defaultTool).not.toBeNull();
expect(defaultTool!.textContent).toContain("test message");
});
// Complete the agent to trigger handler execution
agent.complete();
// Wait for handler execution
await waitFor(() => {
// Only the default agent handler should be called
expect(defaultAgentHandlerCalled).toBe(true);
});
// Log which handlers were called
console.log("Handler calls:", {
defaultAgent: defaultAgentHandlerCalled,
wrongAgent: wrongAgentHandlerCalled,
specificAgent: specificAgentHandlerCalled,
});
// Verify the correct handler was executed and others weren't
expect(defaultAgentHandlerCalled).toBe(true);
expect(wrongAgentHandlerCalled).toBe(false);
expect(specificAgentHandlerCalled).toBe(false);
// Debug: Check what's actually rendered
const defaultTool = screen.queryByTestId("default-agent-tool");
const wrongTool = screen.queryByTestId("wrong-agent-tool");
const specificTool = screen.queryByTestId("specific-agent-tool");
console.log("Tools rendered:", {
default: defaultTool ? "yes" : "no",
wrong: wrongTool ? "yes" : "no",
specific: specificTool ? "yes" : "no",
});
// Check if result is displayed
const resultEl = screen.queryByTestId("default-result");
if (resultEl) {
console.log("Result element found:", resultEl.textContent);
} else {
console.log("No result element found");
}
// The test reveals whether agent scoping works correctly
// If the wrong tool's handler is called, this is a bug in core
});
it("demonstrates that agent scoping prevents execution of tools for wrong agents", async () => {
// This simpler test shows that agent scoping does work for preventing execution
let scopedHandlerCalled = false;
let globalHandlerCalled = false;
const agent = new MockStepwiseAgent();
// Tool scoped to a different agent - should NOT execute
const ScopedTool: React.FC = () => {
const tool: ReactFrontendTool<{ message: string }> = {
name: "scopedTool",
parameters: z.object({ message: z.string() }),
agentId: "differentAgent", // Different from default
render: ({ args, result }) => (
Scoped Tool: {args.message}
{result && (
{JSON.stringify(result)}
)}
),
handler: async (args) => {
scopedHandlerCalled = true;
return { result: `Scoped processed: ${args.message}` };
},
};
useFrontendTool(tool);
return null;
};
// Global tool (no agentId) - SHOULD execute for any agent
const GlobalTool: React.FC = () => {
const tool: ReactFrontendTool<{ message: string }> = {
name: "globalTool",
parameters: z.object({ message: z.string() }),
// No agentId - available to all agents
render: ({ args, result }) => (
>
),
});
// Submit message
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Test scoping" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Test scoping")).toBeDefined();
});
const messageId = testId("msg");
// Try to call the scoped tool - handler should NOT execute
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId: testId("tc1"),
toolCallName: "scopedTool",
parentMessageId: messageId,
delta: '{"message":"trying scoped"}',
}),
);
// Tool should render (renderer is always shown)
await waitFor(() => {
expect(screen.getByTestId("scoped-tool")).toBeDefined();
});
// Call the global tool - handler SHOULD execute
agent.emit(
toolCallChunkEvent({
toolCallId: testId("tc2"),
toolCallName: "globalTool",
parentMessageId: messageId,
delta: '{"message":"trying global"}',
}),
);
await waitFor(() => {
expect(screen.getByTestId("global-tool")).toBeDefined();
});
agent.emit(runFinishedEvent());
agent.complete();
// Wait for the global handler to be called
await waitFor(() => {
expect(globalHandlerCalled).toBe(true);
});
// Verify that only the global handler was called
expect(scopedHandlerCalled).toBe(false); // Should NOT be called (wrong agent)
expect(globalHandlerCalled).toBe(true); // Should be called (no agent restriction)
// The scoped tool should render but have no result
const scopedResult = screen.queryByTestId("scoped-result");
expect(scopedResult).toBeNull();
// The global tool should have a result
await waitFor(() => {
const globalResult = screen.getByTestId("global-result");
expect(globalResult.textContent).toContain(
"Global processed: trying global",
);
});
});
});
describe("Nested Tool Calls", () => {
it("should enable tool calls that render other tools", async () => {
const agent = new MockStepwiseAgent();
let childToolRegistered = false;
// Simple approach: both tools registered at top level
// but one triggers the other through tool calls
const ChildTool: React.FC = () => {
const tool: ReactFrontendTool<{ childValue: string }> = {
name: "childTool",
parameters: z.object({ childValue: z.string() }),
render: ({ args }) => (
>
),
});
// Submit message
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Test error" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Test error")).toBeDefined();
});
// Emit tool call that will error
const messageId = testId("msg");
const toolCallId = testId("tc");
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId,
toolCallName: "errorTool",
parentMessageId: messageId,
delta: '{"shouldError":true,"message":"test error"}',
}),
);
agent.emit(runFinishedEvent());
// Wait for tool to render
await waitFor(() => {
expect(screen.getByTestId("error-tool")).toBeDefined();
});
// Complete the agent to trigger handler execution
agent.complete();
// Wait for handler to be called and error to be thrown
await waitFor(() => {
expect(handlerCalled).toBe(true);
expect(errorThrown).toBe(true);
});
// Wait for the error result to be displayed in the renderer
await waitFor(() => {
const resultEl = screen.getByTestId("error-result");
const resultText = resultEl.textContent || "";
expect(resultText).not.toBe("no-result");
expect(resultText).toContain("Error:");
expect(resultText).toContain("Handler error: test error");
});
// Status should be complete even with error
expect(screen.getByTestId("error-status").textContent).toBe(
ToolCallStatus.Complete,
);
});
it("should handle async errors in handler", async () => {
const agent = new MockStepwiseAgent();
const AsyncErrorTool: React.FC = () => {
const tool: ReactFrontendTool<{ delay: number; errorMessage: string }> =
{
name: "asyncErrorTool",
parameters: z.object({
delay: z.number(),
errorMessage: z.string(),
}),
render: ({ args, status, result }) => (
{status}
Delay: {args.delay}ms
{args.errorMessage}
{result &&
{result}
}
),
handler: async (args) => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, args.delay));
// In test environment, throwing might not propagate as expected
throw new Error(args.errorMessage);
},
};
useFrontendTool(tool);
return null;
};
renderWithCopilotKit({
agent,
children: (
<>
>
),
});
// Submit message
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Test async error" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Test async error")).toBeDefined();
});
// Emit tool call that will error after delay
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId: testId("tc"),
toolCallName: "asyncErrorTool",
parentMessageId: testId("msg"),
delta:
'{"delay":10,"errorMessage":"Async operation failed after delay"}',
}),
);
// Tool should render immediately with args
await waitFor(() => {
expect(screen.getByTestId("async-error-tool")).toBeDefined();
expect(screen.getByTestId("async-delay").textContent).toContain("10ms");
expect(screen.getByTestId("async-error-msg").textContent).toContain(
"Async operation failed",
);
});
// The test verifies that:
// 1. Async tools with delays can render immediately
// 2. Error messages are properly passed through args
// 3. The tool continues to function even with async handlers that may throw
// In production, the error would be caught and sent as a result
// but in test environment, handler execution may not complete
agent.emit(runFinishedEvent());
agent.complete();
});
});
describe("Wildcard Handler", () => {
it("should handle unknown tools with wildcard", async () => {
const agent = new MockStepwiseAgent();
const wildcardHandlerCalls: { name: string; args: any }[] = [];
// Note: Wildcard tools work as fallback renderers when no specific tool is found
// The wildcard renderer receives the original tool name and arguments
const WildcardTool: React.FC = () => {
const tool: ReactFrontendTool = {
name: "*",
parameters: z.any(),
render: ({ name, args, status, result }) => (