Skip to content

fix(prompt): Don't start a new tool call on repeated tool call id#2052

Merged
Anastasiia Zarechneva (zarechneva-a) merged 2 commits into
JetBrains:developfrom
jewoodev:fix/2002-repeated-tool-call-id
Jun 1, 2026
Merged

fix(prompt): Don't start a new tool call on repeated tool call id#2052
Anastasiia Zarechneva (zarechneva-a) merged 2 commits into
JetBrains:developfrom
jewoodev:fix/2002-repeated-tool-call-id

Conversation

@jewoodev

@jewoodev Jewoo Shin (jewoodev) commented May 21, 2026

Copy link
Copy Markdown
Contributor

Closes #2002.

Summary

StreamFrameFlowBuilder.emitToolCallDelta() treated every chunk with a non-null tool-call id as a new tool call. This fix treats a repeated id as a continuation of the pending tool call, so OpenAI-compatible providers that repeat the same id on each streaming chunk no longer fragment one logical tool call into multiple premature ToolCallComplete frames.

Fix

A new tool call now begins only when a present id or index differs from the pending call, instead of whenever id is non-null.

Before:

val new: PendingToolCall = if (sanitizedId != null || index != previous?.index) {

After:

val isNewToolCall =
    (sanitizedId != null && sanitizedId != previous?.id) ||
        (index != null && index != previous?.index)
val new: PendingToolCall = if (isNewToolCall) {

This follows the same id-change principle as emitReasoningDelta; absent or blank ids still continue the pending call (the blank case preserves the #1915 handling). The fix lives in the shared StreamFrameFlowBuilder because all streaming clients (OpenAI, OpenRouter, Mistral, Anthropic, DeepSeek, Ollama, Bedrock, Dashscope, Google) emit tool-call deltas through it.

Tests

Two regression tests in StreamFrameFlowBuilderTest: testEmitToolCallDeltaWithRepeatedIdAppendsToExisting (three chunks with the same id combine into one ToolCallComplete) and testEmitToolCallDeltaWithRepeatedIdSeparatesDistinctToolCalls (switching to a different id still starts a separate tool call). :prompt:prompt-model:jvmTest (20 tests, 0 failures), wasmJsNodeTest, and ktlintCheck all pass locally; the full ./gradlew build (Android and browser targets) is left to CI, since this contributor environment lacks the Android SDK and a headless browser.

`emitToolCallDelta()` started a new tool call whenever the chunk `id` was
non-null. Some OpenAI-compatible providers repeat the same `id` on every
streaming chunk, so a single tool call was fragmented into multiple early
`ToolCallComplete` frames.

Detect a new tool call only when a present `id`/`index` signal differs from
the pending call, matching the existing logic in `emitReasoningDelta`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the fix! Looks good!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you!

@zarechneva-a
Anastasiia Zarechneva (zarechneva-a) merged commit 581d6af into JetBrains:develop Jun 1, 2026
22 checks passed
@jewoodev

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for landing the OpenTelemetry test fix on top to unblock CI — really appreciated.

@jewoodev
Jewoo Shin (jewoodev) deleted the fix/2002-repeated-tool-call-id branch June 1, 2026 16:24
Andrey Bragin (EugeneTheDev) pushed a commit that referenced this pull request Jun 15, 2026
)

Closes #2002.

### Summary

`StreamFrameFlowBuilder.emitToolCallDelta()` treated every chunk with a
non-null tool-call `id` as a new tool call. This fix treats a repeated
`id` as a continuation of the pending tool call, so OpenAI-compatible
providers that repeat the same `id` on each streaming chunk no longer
fragment one logical tool call into multiple premature
`ToolCallComplete` frames.

### Fix

A new tool call now begins only when a present `id` or `index` differs
from the pending call, instead of whenever `id` is non-null.

Before:

```kotlin
val new: PendingToolCall = if (sanitizedId != null || index != previous?.index) {
```

After:

```kotlin
val isNewToolCall =
    (sanitizedId != null && sanitizedId != previous?.id) ||
        (index != null && index != previous?.index)
val new: PendingToolCall = if (isNewToolCall) {
```

This follows the same id-change principle as `emitReasoningDelta`;
absent or blank ids still continue the pending call (the blank case
preserves the #1915 handling). The fix lives in the shared
`StreamFrameFlowBuilder` because all streaming clients (OpenAI,
OpenRouter, Mistral, Anthropic, DeepSeek, Ollama, Bedrock, Dashscope,
Google) emit tool-call deltas through it.

### Tests

Two regression tests in `StreamFrameFlowBuilderTest`:
`testEmitToolCallDeltaWithRepeatedIdAppendsToExisting` (three chunks
with the same `id` combine into one `ToolCallComplete`) and
`testEmitToolCallDeltaWithRepeatedIdSeparatesDistinctToolCalls`
(switching to a different `id` still starts a separate tool call).
`:prompt:prompt-model:jvmTest` (20 tests, 0 failures), `wasmJsNodeTest`,
and `ktlintCheck` all pass locally; the full `./gradlew build` (Android
and browser targets) is left to CI, since this contributor environment
lacks the Android SDK and a headless browser.

---------

Co-authored-by: Anastasiia.Zarechneva <Anastasiia.Zarechneva@jetbrains.com>
(cherry picked from commit 581d6af)
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.

StreamFrameFlowBuilder.emitToolCallDelta() incorrectly treats every chunk with non-null id as a new tool call

3 participants