mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(react-core): render every v1 tool call (#6682)
## What does this PR do? Fixes the v1 compatibility render path so an assistant message can render every tool call instead of only `toolCalls[0]`. The returned lazy renderer now: - matches each tool call with its corresponding tool result; - renders all registered tool-call renderers in one fragment; - removes `null` render results and returns `null` when no tool has a renderer. Keeping the fragment behind the existing lazy-renderer callback preserves the exported `useLazyToolRenderer` return signature. Filtering before returning also avoids attaching empty generative UI, so caller-provided subcomponents are not suppressed when no renderer is registered. Regression tests cover multiple tool calls, per-call result matching, the all-unhandled case, and a mixed handled/unhandled message. ## Related PRs and Issues - Fixes #2946 ## Verification - `pnpm nx run @copilotkit/react-core:test` (133 files, 1,530 Vitest tests plus 47 script tests) - `pnpm nx run @copilotkit/react-core:check-types` - `pnpm exec oxfmt --check packages/react-core/src/v1-deprecated/hooks/use-lazy-tool-renderer.tsx packages/react-core/src/v1-deprecated/hooks/__tests__/use-lazy-tool-renderer.test.tsx` ## Checklist - [x] I have read the [Contribution Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md) - [x] Documentation is unchanged because this restores existing v1 behavior without changing the public API - [x] "Allow edits by maintainers" is checked
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { render, renderHook, screen } from "@testing-library/react";
|
||||
import type { AIMessage, Message } from "@copilotkit/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRenderToolCall } from "../../../v2";
|
||||
import { useLazyToolRenderer } from "../use-lazy-tool-renderer";
|
||||
|
||||
vi.mock("../../../v2", () => ({
|
||||
useRenderToolCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
id: "weather-call",
|
||||
type: "function" as const,
|
||||
function: { name: "get_weather", arguments: "{}" },
|
||||
},
|
||||
{
|
||||
id: "time-call",
|
||||
type: "function" as const,
|
||||
function: { name: "get_time", arguments: "{}" },
|
||||
},
|
||||
];
|
||||
|
||||
const message = {
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls,
|
||||
} as AIMessage;
|
||||
|
||||
describe("useLazyToolRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders every tool call and matches each tool result", () => {
|
||||
const renderToolCall = vi.fn(({ toolCall, toolMessage }) => (
|
||||
<div data-testid="tool-call">
|
||||
{toolCall.function.name}:{toolMessage?.content}
|
||||
</div>
|
||||
));
|
||||
vi.mocked(useRenderToolCall).mockReturnValue(renderToolCall);
|
||||
|
||||
const messages = [
|
||||
message,
|
||||
{
|
||||
id: "weather-result",
|
||||
role: "tool",
|
||||
toolCallId: "weather-call",
|
||||
content: "sunny",
|
||||
},
|
||||
{
|
||||
id: "time-result",
|
||||
role: "tool",
|
||||
toolCallId: "time-call",
|
||||
content: "noon",
|
||||
},
|
||||
] as Message[];
|
||||
const { result } = renderHook(() => useLazyToolRenderer());
|
||||
|
||||
const lazyRenderer = result.current(message, messages);
|
||||
expect(lazyRenderer).not.toBeNull();
|
||||
render(lazyRenderer!());
|
||||
|
||||
expect(screen.getAllByTestId("tool-call")).toHaveLength(2);
|
||||
expect(screen.getByText("get_weather:sunny")).toBeTruthy();
|
||||
expect(screen.getByText("get_time:noon")).toBeTruthy();
|
||||
expect(renderToolCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns null when none of the tool calls have a renderer", () => {
|
||||
vi.mocked(useRenderToolCall).mockReturnValue(vi.fn(() => null));
|
||||
const { result } = renderHook(() => useLazyToolRenderer());
|
||||
|
||||
const lazyRenderer = result.current(message, []);
|
||||
|
||||
expect(lazyRenderer).not.toBeNull();
|
||||
expect(lazyRenderer!()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps rendered tool calls when another renderer returns null", () => {
|
||||
vi.mocked(useRenderToolCall).mockReturnValue(
|
||||
vi.fn(({ toolCall }) =>
|
||||
toolCall.id === "time-call" ? <div>time renderer</div> : null,
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useLazyToolRenderer());
|
||||
|
||||
const lazyRenderer = result.current(message, []);
|
||||
render(lazyRenderer!());
|
||||
|
||||
expect(screen.getByText("time renderer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -30,19 +30,26 @@ export function useLazyToolRenderer(): (
|
||||
return useCallback(
|
||||
(message?: AIMessage, messages?: Message[]) => {
|
||||
if (!message?.toolCalls?.length) return null;
|
||||
const toolCalls = message.toolCalls;
|
||||
|
||||
const toolCall = message.toolCalls[0];
|
||||
if (!toolCall) return null;
|
||||
return () => {
|
||||
const renderedToolCalls = toolCalls
|
||||
.map((toolCall) => {
|
||||
const toolMessage = messages?.find(
|
||||
(m) => m.role === "tool" && m.toolCallId === toolCall.id,
|
||||
) as ToolResult;
|
||||
|
||||
const toolMessage = messages?.find(
|
||||
(m) => m.role === "tool" && m.toolCallId === toolCall.id,
|
||||
) as ToolResult;
|
||||
return renderToolCall({
|
||||
toolCall,
|
||||
toolMessage,
|
||||
});
|
||||
})
|
||||
.filter((renderedToolCall) => renderedToolCall !== null);
|
||||
|
||||
return () =>
|
||||
renderToolCall({
|
||||
toolCall,
|
||||
toolMessage,
|
||||
});
|
||||
if (!renderedToolCalls.length) return null;
|
||||
|
||||
return <>{renderedToolCalls}</>;
|
||||
};
|
||||
},
|
||||
[renderToolCall],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user