fix(react-core): preserve assistant text when multiple tool calls fire in one turn (CPK-7154)#3622
Merged
marthakelly merged 25 commits intomainfrom Apr 10, 2026
Conversation
🦋 Changeset detectedLatest 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 |
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📣 Social Copy GeneratorGenerate social media copies (Twitter/X, LinkedIn, Blog Post) for this PR using Claude.
|
@copilotkit/a2ui-renderer
@copilotkitnext/angular
copilotkit
@copilotkit/core
@copilotkit/react-core
@copilotkit/react-textarea
@copilotkit/react-ui
@copilotkit/runtime
@copilotkit/runtime-client-gql
@copilotkit/sdk-js
@copilotkit/shared
@copilotkit/sqlite-runner
@copilotkit/voice
@copilotkit/web-inspector
commit: |
…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>
4bce1ad to
4a44b66
Compare
…e utility and refactoring tests for clarity
…oolCall usage in tests
…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
previously approved these changes
Apr 8, 2026
Contributor
tylerslaton
left a comment
There was a problem hiding this comment.
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
- JSDoc inaccurately describes toolCalls behavior —
CopilotChatMessageView.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."
- Test "uses latest content" does not assert content value —
CopilotChatMessageView.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.");- No test for empty-array
[]vsundefinedtoolCalls —CopilotChatMessageView.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
- Add test for
content: undefined(distinct from"")
All tests use""to represent wiped content, butAssistantMessage.contentisstring | undefined. A test with explicitlyundefinedcontent would document that||handles both cases and guard against a future||→??refactor. - Redundant
as AssistantMessagetype assertions
Lines 328-334. SinceMessageis aZodDiscriminatedUniononrole, checkingmessage.role === "assistant"already narrows the type. The casts on lines 328-329 and 333-334 are redundant (the one on line 340 foracc.set()is justified due to TypeScript spread-type limitations). - Consider
@internalannotation on export
deduplicateMessagesis exported for testability but not re-exported through the barrel file. Adding@internalto the JSDoc would signal this isn't part of the public API.(1) Missing changeset —CopilotChatMessageView.tsx - No
.changeset/*.mdfile is present.
The changeset bot has flagged this. This is a patch-level bug fix for@copilotkit/react-coreand must be added before merge.
- 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>
…ttps://github.com/CopilotKit/CopilotKit into fix/CPK-7154-agent-text-wiped-multiple-tool-calls
ranst91
approved these changes
Apr 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CopilotChatMessageViewdeduplicated messages withnew 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.contentfrom any earlier occurrence while keeping the latesttoolCalls(which accumulate). All other message roles retain "keep last" behavior.Test plan
"preserves assistant text content when later duplicate has empty content (multi-tool-call scenario)"passes@copilotkit/react-coretest suite: 950 passed, 0 failed (baseline was 949)Closes #3470
🤖 Generated with Claude Code