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:
Ben Taylor
2026-08-30 16:59:05 -05:00
committed by GitHub
2 changed files with 112 additions and 10 deletions
@@ -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],
);