Skip to content

fix(react-core): preserve assistant text when multiple tool calls fire in one turn (CPK-7154)#3622

Merged
marthakelly merged 25 commits intomainfrom
fix/CPK-7154-agent-text-wiped-multiple-tool-calls
Apr 10, 2026
Merged

fix(react-core): preserve assistant text when multiple tool calls fire in one turn (CPK-7154)#3622
marthakelly merged 25 commits intomainfrom
fix/CPK-7154-agent-text-wiped-multiple-tool-calls

Conversation

@marthakelly
Copy link
Copy Markdown
Contributor

Summary

  • Root cause: CopilotChatMessageView deduplicated messages with new Map(messages.map(m => [m.id, m])), which keeps only the last occurrence of each ID. During streaming, when an agent fires multiple tool calls in one turn, the same message ID appears multiple times: first with text content, then with empty content + tool calls appended. The last entry wins, wiping the text.
  • Fix: Replace "keep last" with a merge strategy for assistant messages — recover non-empty content from any earlier occurrence while keeping the latest toolCalls (which accumulate). All other message roles retain "keep last" behavior.
  • Test: Added a regression test covering the three-occurrence scenario (text → first tool call → second tool call) to confirm the merged message renders both the original text and the final tool call set.

Test plan

  • New test "preserves assistant text content when later duplicate has empty content (multi-tool-call scenario)" passes
  • Existing dedup tests (single duplicate, order preservation, activity rendering) all pass unchanged
  • Full @copilotkit/react-core test suite: 950 passed, 0 failed (baseline was 949)

Closes #3470

🤖 Generated with Claude Code

@marthakelly marthakelly requested a review from mme as a code owner April 4, 2026 15:45
@linear
Copy link
Copy Markdown

linear Bot commented Apr 4, 2026

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 4, 2026

🦋 Changeset detected

Latest commit: 1e3069e

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat-with-your-data Ready Ready Preview, Comment Apr 10, 2026 3:20pm
docs Ready Ready Preview, Comment Apr 10, 2026 3:20pm
form-filling Ready Ready Preview, Comment Apr 10, 2026 3:20pm
research-canvas Ready Ready Preview, Comment Apr 10, 2026 3:20pm
travel Ready Ready Preview, Comment Apr 10, 2026 3:20pm

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 4, 2026

📣 Social Copy Generator

Generate social media copies (Twitter/X, LinkedIn, Blog Post) for this PR using Claude.

  • Generate social media copies

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 4, 2026

Open in StackBlitz

@copilotkit/a2ui-renderer

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/a2ui-renderer@3622

@copilotkitnext/angular

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkitnext/angular@3622

copilotkit

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/copilotkit@3622

@copilotkit/core

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/core@3622

@copilotkit/react-core

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/react-core@3622

@copilotkit/react-textarea

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/react-textarea@3622

@copilotkit/react-ui

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/react-ui@3622

@copilotkit/runtime

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/runtime@3622

@copilotkit/runtime-client-gql

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/runtime-client-gql@3622

@copilotkit/sdk-js

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/sdk-js@3622

@copilotkit/shared

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/shared@3622

@copilotkit/sqlite-runner

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/sqlite-runner@3622

@copilotkit/voice

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/voice@3622

@copilotkit/web-inspector

pnpm add https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/web-inspector@3622

commit: 1e3069e

…e content in same turn (CPK-7154)

During streaming, the same message ID can arrive multiple times as tool calls are appended.
The previous "keep last" dedup lost any text content that was streamed before the first tool call,
because later entries carry empty content. Replace with a merge strategy: for assistant messages,
recover non-empty content from earlier occurrences while keeping the latest toolCalls accumulation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…add test

The { ...existing, ...message } spread meant a later streaming chunk with
toolCalls: undefined would silently wipe accumulated tool calls, contradicting
the comment's claim that "latest toolCalls wins". Apply the same ?? recovery
logic to toolCalls as content already uses for the || fallback.

Add a test for the flip-side edge case: first occurrence has toolCalls, second
has non-empty content but undefined toolCalls — toolCalls must survive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tylerslaton
tylerslaton previously approved these changes Apr 8, 2026
Copy link
Copy Markdown
Contributor

@tylerslaton tylerslaton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! Posting a few comments from AI, would be good to resolve them but not required. Approving and you can decide how to proceed.


Issues

  1. JSDoc inaccurately describes toolCalls behaviorCopilotChatMessageView.tsx:308-313

    The JSDoc says "while keeping the latest toolCalls" but the implementation (lines 332-334) uses ?? which recovers earlier toolCalls when the latest is undefined. The inline comment on lines 330-331 is accurate and directly contradicts the JSDoc summary. Since the JSDoc is what consumers read first on this exported function, it should be corrected to say something like: "and similarly recovers toolCalls from earlier occurrences if the latest is undefined."
  1. Test "uses latest content" does not assert content valueCopilotChatMessageView.test.tsx:136-165

    The test "uses latest content when all assistant duplicates have non-empty content" verifies message counts and absence of duplicate-key warnings, but never asserts which content value was kept. The test name claims latest content wins but nothing proves it. Should add:
expect(assistantMessages[0].textContent).toContain("Full response from the assistant.");
  1. No test for empty-array [] vs undefined toolCallsCopilotChatMessageView.test.tsx

    The ?? operator for toolCalls is the critical design choice distinguishing "explicitly no tool calls" ([]) from "field not provided" (undefined). No test locks in this behavior. If someone later changes ?? to ||, the regression would go undetected. Suggested test:
it("keeps empty toolCalls array from later chunk (does not fall back)", () => {
  const messages: Message[] = [
    assistantMsg("a-1", "", [toolCall("tc-1", "fn")]),
    assistantMsg("a-1", "Done.", []),
  ];
  const result = deduplicateMessages(messages);
  expect(result).toHaveLength(1);
  expect((result[0] as AssistantMessage).toolCalls).toEqual([]);
});

Suggestions

  1. Add test for content: undefined (distinct from "")

    All tests use "" to represent wiped content, but AssistantMessage.content is string | undefined. A test with explicitly undefined content would document that || handles both cases and guard against a future ||?? refactor.
  2. Redundant as AssistantMessage type assertions

    Lines 328-334. Since Message is a ZodDiscriminatedUnion on role, checking message.role === "assistant" already narrows the type. The casts on lines 328-329 and 333-334 are redundant (the one on line 340 for acc.set() is justified due to TypeScript spread-type limitations).
  3. Consider @internal annotation on export

    deduplicateMessages is exported for testability but not re-exported through the barrel file. Adding @internal to the JSDoc would signal this isn't part of the public API.(1) Missing changesetCopilotChatMessageView.tsx
  4. No .changeset/*.md file is present.

    The changeset bot has flagged this. This is a patch-level bug fix for @copilotkit/react-core and must be added before merge.

marthakelly and others added 12 commits April 9, 2026 07:58
- Fix JSDoc: "while keeping the latest toolCalls" was wrong for the ?? case;
  now says "recovers toolCalls from earlier occurrences if the latest is
  undefined" and notes that [] is treated as intentional
- Add @internal annotation to signal export is for testing only
- Remove redundant AssistantMessage casts inside the role-narrowed branch
- Add missing content assertion to "uses latest content" render test
- Add test: [] toolCalls from later chunk is kept (not fallen back from)
- Add test: undefined content on both sides is handled without error
- Add changeset for @copilotkit/react-core patch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent text response lost when multiple tool calls in single turn (dedup replaces text + earlier tools)

4 participants