fix(translator): synthesize tool call chunks from response.completed batched output (#7613)

* fix(translator): synthesize tool call chunks from response.completed output[]

When an upstream provider sends a batched response.completed event carrying
function_call items in its data.response.output[] array — without having
sent the individual response.output_item.added / .delta / .done events —
the state variables toolCallIndex and currentToolCallId were never set,
causing computeFinishReason to return 'stop' instead of 'tool_calls'.

This broke the agent loop for downstream Chat Completions clients
(OpenCode, Hermes, etc.) when routing through providers that batch their
output into the completed event.

Fix: parse data.response.output[] for function_call items in the
response.completed handler, synthesize the tool call header + arguments
delta chunks, advance state, and emit finish_reason: 'tool_calls'.

Also updates withAssistantRoleOnFirstDelta to handle array results.

Fixes #180, #3980
Refs: https://github.com/diegosouzapw/OmniRoute/issues/180
Refs: https://github.com/diegosouzapw/OmniRoute/issues/3980

* fix(translator): guard against double-emission for incrementally-streamed tool calls

Add a guard that skips response.completed synthesis for call_ids already
tracked via incremental output_item.added/.done events. Without this,
providers that stream incrementally AND echo function_call items in the
response.completed output[] snapshot get duplicate tool call chunks.

Also adds a regression test combining both incremental events and a
response.completed snapshot in the same turn.

Refs: diegosouzapw/OmniRoute#7613

* chore: add docker-compose.yml.bak to gitignore

* refactor(translator): extract response.completed synthesis, fix ratchets

Fixes the file-size and complexity/cognitive-complexity ratchet
regressions the dedup-guard commit (6bbff5ea) introduced, so the PR's
own validation block (typecheck, eslint, file-size, complexity,
cognitive-complexity, changelog-integrity, test-discovery) is fully
green, not just its own tests:

- Extract the response.completed batched-tool-call synthesis body into
  a new leaf module
  open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts,
  mirroring this file's own established eventEmitter.ts/toolSchemas.ts/
  pureHelpers.ts extraction pattern, further split internally
  (buildToolCallChunks/buildFinalChunk/resolveArgsStr/baseChunk) to
  keep synthesizeCompletedToolCalls() itself under the complexity/
  cognitive-complexity/max-lines-per-function thresholds.
- computeFinishReason moves alongside it (not into the sibling
  pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is
  guarded by tests/unit/response-openai-responses-purehelpers-split.test.ts
  to have NO state coupling at all.
- DRY the withAssistantRoleOnFirstDelta array/single-result branches
  into a shared setAssistantRoleIfEligible(state, delta) helper — the
  array branch alone pushed this function's cyclomatic complexity to
  16 (over the 15 threshold).
- Split the 5 new response.completed tests out of
  tests/unit/translator-resp-openai-responses.test.ts (which would
  have exceeded the 800-line test-file-size cap) into a new sibling
  tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts.
- Bump the frozen open-sse/translator/response/openai-responses.ts
  file-size baseline for the small irreducible remainder (dedup-guard
  state tracking + call-site wiring) with a justification entry in
  config/quality/file-size-baseline.json.

Independently re-verified red-first (temporarily reintroducing the
pre-guard filter reproduces exactly one failure — the dedup test — no
collateral damage) and confirmed check:complexity-ratchets is back to
exactly baseline (2058/890) and check:file-size is fully green.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(translator): move completed-tool-call glue into module (file-size cap)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Erick Kinnee <erick@ekinnee.dev>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Erick Kinnee
2026-07-18 19:20:01 -05:00
committed by GitHub
parent 9152e3d8f5
commit d6e86f413b
6 changed files with 522 additions and 36 deletions

1
.gitignore vendored
View File

@@ -249,3 +249,4 @@ _artifacts/ # release-green artifacts
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak

View File

@@ -0,0 +1 @@
- **fix(translator):** synthesize tool call chunks from `response.completed` batched output when upstream omits individual `output_item.added`/`done` events, fixing `finish_reason: "stop"` instead of `"tool_calls"` for agentic clients ([#180](https://github.com/diegosouzapw/OmniRoute/issues/180), [#3980](https://github.com/diegosouzapw/OmniRoute/issues/3980))

View File

@@ -0,0 +1 @@
- **fix(translator):** guard against double-emission when `response.completed` echoes `function_call` items already streamed via incremental `output_item.added`/`done` events — skip synthesis for `call_id`s already tracked, preventing duplicate tool call chunks for incrementally-streaming providers

View File

@@ -15,6 +15,11 @@ import {
getVisibleResponsesReasoningSummaryText,
} from "./openai-responses/pureHelpers.ts";
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
import {
synthesizeCompletedToolCalls,
computeFinishReason,
withAssistantRoleOnFirstDelta,
} from "./openai-responses/synthesizeCompletedToolCalls.ts";
// normalizeUpstreamFailure is re-exported for external importers (tests).
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
@@ -609,42 +614,6 @@ function flushEvents(state) {
return events;
}
/**
* OpenAI Chat Completions streams announce the assistant role on the FIRST delta
* (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant",
* "tool_calls": [...] }`). The Responses API has no role-announcement event, so when
* translating Responses → Chat we must synthesize it on the first emitted chunk.
*
* Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk`
* (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk.
* Without it, streamed tool_call deltas are dropped and the agent returns an empty
* response, even though the underlying tool call is well-formed.
*/
function withAssistantRoleOnFirstDelta(state, result) {
if (!result || state.roleEmitted) return result;
const delta = result.choices?.[0]?.delta;
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
delta.role = "assistant";
state.roleEmitted = true;
}
return result;
}
/**
* Resolve the terminal finish_reason for a Responses→Chat stream.
*
* `currentToolCallId` is intentionally sticky for the current turn: it is set when a
* function_call item is announced (`response.output_item.added`) and is only cleared once
* the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends
* (flush or `response.completed`) after a tool call was emitted but BEFORE its
* `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set.
* Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so
* OpenAI-compatible clients continue tool-result processing instead of stopping prematurely.
*/
function computeFinishReason(state): "tool_calls" | "stop" {
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
}
// #5786 — remember that a reasoning delta was streamed for a given reasoning item, so
// the terminal `response.output_item.done` snapshot for that item is NOT re-emitted
// (which would duplicate the reasoning channel). Keyed by item_id when present, with a
@@ -770,6 +739,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
state.currentToolCallDeferred = false;
// Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
const toolName = normalizeToolName(item.name);
if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names.
@@ -849,6 +822,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const toolName = normalizeToolName(item.name);
const toolSchema = state.toolSchemas?.get(toolName);
// Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (callId) state.toolCallIdsSeen.add(callId);
if (state.currentToolCallDeferred) {
state.currentToolCallDeferred = false;
state.currentToolCallArgsBuffer = "";
@@ -979,6 +956,15 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
}
}
// #fix: synthesize tool call chunks from response.completed output[] for
// providers that batch everything into response.completed without prior
// incremental output_item.* events — including the dedup guard against
// providers that DO stream incrementally and also echo the same
// function_call items here. See synthesizeCompletedToolCalls's own
// doc-comment for the full rationale.
const synthesized = synthesizeCompletedToolCalls(state, data.response?.output);
if (synthesized) return synthesized;
if (!state.finishReasonSent) {
state.finishReasonSent = true;
const reason = computeFinishReason(state);

View File

@@ -0,0 +1,198 @@
// Extracted from response/openai-responses.ts (file-size ratchet) — synthesizes
// tool_calls chunks from a response.completed event's output[] snapshot, for
// upstream providers that send a single batched completed event WITHOUT first
// emitting the individual response.output_item.added/.delta/.done events.
// Without this, state.toolCallIndex stays 0 and state.currentToolCallId stays
// null, so computeFinishReason returns "stop" instead of "tool_calls",
// breaking the agent loop for downstream Chat Completions clients.
import { fallbackToolCallId } from "../../helpers/toolCallHelper.ts";
import { normalizeToolName, stripEmptyOptionalToolArgs } from "./pureHelpers.ts";
/**
* Resolve the terminal finish_reason for a Responses→Chat stream.
*
* `currentToolCallId` is intentionally sticky for the current turn: it is set when a
* function_call item is announced (`response.output_item.added`) and is only cleared once
* the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends
* (flush or `response.completed`) after a tool call was emitted but BEFORE its
* `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set.
* Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so
* OpenAI-compatible clients continue tool-result processing instead of stopping prematurely.
*
* Lives here (not pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is
* guarded (tests/unit/response-openai-responses-purehelpers-split.test.ts) to have NO
* state coupling at all.
*/
export function computeFinishReason(state): "tool_calls" | "stop" {
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
}
/**
* OpenAI Chat Completions streams announce the assistant role on the FIRST delta
* (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant",
* "tool_calls": [...] }`). The Responses API has no role-announcement event, so when
* translating Responses → Chat we must synthesize it on the first emitted chunk.
*
* Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk`
* (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk.
* Without it, streamed tool_call deltas are dropped and the agent returns an empty
* response, even though the underlying tool call is well-formed.
*/
// Shared by both branches of withAssistantRoleOnFirstDelta below: stamps
// role: "assistant" onto a single delta object when eligible, returning
// whether it did so (used to short-circuit the array branch's loop).
function setAssistantRoleIfEligible(state, delta) {
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
delta.role = "assistant";
state.roleEmitted = true;
return true;
}
return false;
}
export function withAssistantRoleOnFirstDelta(state, result) {
if (!result || state.roleEmitted) return result;
// Handle arrays of chunks (e.g. synthesized from response.completed output[])
if (Array.isArray(result)) {
for (const chunk of result) {
if (setAssistantRoleIfEligible(state, chunk?.choices?.[0]?.delta)) break;
}
return result;
}
setAssistantRoleIfEligible(state, result.choices?.[0]?.delta);
return result;
}
function baseChunk(state): Record<string, unknown> {
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
};
}
/** Resolve the arguments string to emit — may arrive as a string or object. */
function resolveArgsStr(rawArgs, toolName, toolSchema): string {
const argsToEmit = stripEmptyOptionalToolArgs(rawArgs, toolName, toolSchema);
if (argsToEmit != null) {
return typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
}
if (rawArgs != null) {
return typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
}
return "";
}
/**
* Build the header + args chunks for one synthesized function_call item,
* mutating `state` exactly as the incremental output_item.added/.done path
* would (currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred,
* toolCallIndex), so downstream chunk math (computeFinishReason, subsequent
* incremental events in the same turn) stays consistent.
*/
function buildToolCallChunks(state, fcItem): Record<string, unknown>[] {
const chunks: Record<string, unknown>[] = [];
const callId = fcItem.call_id || fallbackToolCallId(state.toolCallIndex);
const toolName = normalizeToolName(fcItem.name);
const toolSchema = state.toolSchemas?.get(toolName);
// Set state as output_item.added would
state.currentToolCallId = callId;
state.currentToolCallArgsBuffer = "";
state.currentToolCallDeferred = false;
// Emit the tool call header chunk (id, type, function.name)
const currentIndex = state.toolCallIndex;
chunks.push({
...baseChunk(state),
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentIndex,
id: callId,
type: "function",
function: { name: toolName || "", arguments: "" },
},
],
},
finish_reason: null,
},
],
});
const argsStr = resolveArgsStr(fcItem.arguments, toolName, toolSchema);
if (argsStr) {
state.currentToolCallArgsBuffer = argsStr;
chunks.push({
...baseChunk(state),
choices: [
{
index: 0,
delta: { tool_calls: [{ index: currentIndex, function: { arguments: argsStr } }] },
finish_reason: null,
},
],
});
}
// Advance state as output_item.done would
state.toolCallIndex++;
state.currentToolCallArgsBuffer = "";
state.currentToolCallId = null;
return chunks;
}
/** Build the terminal chunk (finish_reason + usage) once all tool calls are synthesized. */
function buildFinalChunk(state): Record<string, unknown> {
state.finishReasonSent = true;
const reason = computeFinishReason(state);
state.finishReason = reason;
const finalChunk: Record<string, unknown> = {
...baseChunk(state),
choices: [{ index: 0, delta: {}, finish_reason: reason }],
};
if (state.usage && typeof state.usage === "object") {
finalChunk.usage = state.usage;
}
return finalChunk;
}
/**
* Synthesize chat-completion-style tool_calls chunks for any `function_call`
* items in `output` whose call_id was NOT already tracked via incremental
* `output_item.added`/`.done` events (`state.toolCallIdsSeen`). This dedup
* guard prevents double-emission when a provider streams incrementally AND
* `response.completed` also echoes the same function_call items in its
* output[] snapshot (standard Responses-API snapshot behavior).
*
* Mutates `state` exactly as the incremental path would (toolCallIndex,
* currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred,
* finishReasonSent, finishReason), so downstream chunk math stays consistent.
*
* Returns the array of synthesized chunks, or `null` when there is nothing to
* synthesize (no un-seen function_call items, or finish_reason already sent)
* — the caller falls through to its own default finish_reason handling.
*/
export function synthesizeCompletedToolCalls(state, output): Record<string, unknown>[] | null {
const outputItems = Array.isArray(output) ? output : [];
const functionCallItems = outputItems.filter(
(item) => item?.type === "function_call" && !state.toolCallIdsSeen?.has(item.call_id)
);
if (functionCallItems.length === 0 || state.finishReasonSent) return null;
const synthesizedChunks: Record<string, unknown>[] = [];
for (const fcItem of functionCallItems) {
synthesizedChunks.push(...buildToolCallChunks(state, fcItem));
}
synthesizedChunks.push(buildFinalChunk(state));
return synthesizedChunks;
}

View File

@@ -0,0 +1,299 @@
import test from "node:test";
import assert from "node:assert/strict";
// Split out of translator-resp-openai-responses.test.ts (file-size ratchet —
// this file plus the new tests would have exceeded both the production and
// test-file frozen baselines). Covers the response.completed batched
// tool-call synthesis path (no prior incremental output_item.* events) and
// its dedup guard against providers that DO stream incrementally and then
// echo the same function_call items in response.completed's output[]
// snapshot (would otherwise double-emit — see openai-responses.ts's
// `toolCallIdsSeen` guard).
const { openaiResponsesToOpenAIResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
test("Responses -> OpenAI: response.completed with function_call in output[] synthesizes tool call chunks", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_1",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: { path: "/tmp/a" },
},
],
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
},
},
},
state
);
// Should return an array of chunks (header + args + final)
assert.ok(Array.isArray(result), "should return array of chunks");
assert.equal(result.length, 3, "should have 3 chunks: header, args, final");
// First chunk: tool call header with id, type, function.name
const header = result[0];
assert.equal(header.choices[0].delta.tool_calls[0].id, "call_1");
assert.equal(header.choices[0].delta.tool_calls[0].type, "function");
assert.equal(header.choices[0].delta.tool_calls[0].function.name, "read_file");
assert.equal(header.choices[0].delta.tool_calls[0].function.arguments, "");
assert.equal(header.choices[0].finish_reason, null);
// Second chunk: arguments delta
const argsChunk = result[1];
assert.equal(argsChunk.choices[0].delta.tool_calls[0].index, 0);
assert.equal(
argsChunk.choices[0].delta.tool_calls[0].function.arguments,
JSON.stringify({ path: "/tmp/a" })
);
assert.equal(argsChunk.choices[0].finish_reason, null);
// Third chunk: final with finish_reason
const final = result[2];
assert.equal(final.choices[0].finish_reason, "tool_calls");
assert.equal(final.usage.prompt_tokens, 5);
assert.equal(final.usage.completion_tokens, 3);
});
test("Responses -> OpenAI: response.completed with multiple function_calls in output[]", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_2",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_a",
name: "read_file",
arguments: { path: "/tmp/a" },
},
{
type: "function_call",
call_id: "call_b",
name: "write_file",
arguments: { path: "/tmp/b", content: "hello" },
},
],
usage: {
input_tokens: 10,
output_tokens: 6,
total_tokens: 16,
},
},
},
state
);
assert.ok(Array.isArray(result), "should return array of chunks");
// 2 tool calls = 2 headers + 2 args + 1 final = 5 chunks
assert.equal(result.length, 5, "should have 5 chunks for 2 tool calls");
// First tool call header
assert.equal(result[0].choices[0].delta.tool_calls[0].id, "call_a");
assert.equal(result[0].choices[0].delta.tool_calls[0].function.name, "read_file");
// Second tool call header
assert.equal(result[2].choices[0].delta.tool_calls[0].id, "call_b");
assert.equal(result[2].choices[0].delta.tool_calls[0].function.name, "write_file");
// Final chunk
const final = result[4];
assert.equal(final.choices[0].finish_reason, "tool_calls");
assert.equal(final.usage.prompt_tokens, 10);
});
test("Responses -> OpenAI: response.completed without function_call in output[] still returns stop", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_3",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Hello!" }],
},
],
usage: {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
},
},
},
state
);
// Should return a single chunk (not array) with finish_reason: "stop"
assert.ok(!Array.isArray(result), "should return single chunk, not array");
assert.equal(result.choices[0].finish_reason, "stop");
assert.equal(result.usage.prompt_tokens, 5);
});
test("Responses -> OpenAI: response.completed with function_call in output[] sets assistant role on first delta", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_4",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: { path: "/tmp/a" },
},
],
},
},
state
);
assert.ok(Array.isArray(result));
// First chunk should have role: "assistant" in delta
assert.equal(result[0].choices[0].delta.role, "assistant");
assert.equal(state.roleEmitted, true);
});
test("Responses -> OpenAI: incremental tool call events + response.completed snapshot does NOT double-emit", () => {
// Regression test: when a provider streams incrementally (output_item.added ->
// function_call_arguments.delta -> output_item.done) and then response.completed
// echoes the same function_call items in its output[] snapshot, we must NOT
// synthesize a second set of tool call chunks for already-seen call_ids.
const state = {};
// Phase 1: incremental events for call_a (identical to real streaming provider)
const added = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_a", name: "read_file" },
},
state
);
assert.ok(added, "should emit header chunk for incremental tool call");
assert.equal(added.choices[0].delta.tool_calls[0].id, "call_a");
const args = openaiResponsesToOpenAIResponse(
{
type: "response.function_call_arguments.delta",
delta: '{"path":"/tmp/a"}',
},
state
);
assert.ok(args, "should emit args delta chunk");
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_a",
name: "read_file",
arguments: { path: "/tmp/a" },
},
},
state
);
// Phase 2: incremental events for call_b
const addedB = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_b", name: "write_file" },
},
state
);
assert.ok(addedB);
openaiResponsesToOpenAIResponse(
{
type: "response.function_call_arguments.delta",
delta: '{"path":"/tmp/b","content":"hello"}',
},
state
);
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_b",
name: "write_file",
arguments: { path: "/tmp/b", content: "hello" },
},
},
state
);
// Phase 3: response.completed echoes BOTH call_a and call_b in output[]
// This is the test: the guard should skip synthesis since both call_ids were
// already tracked via the incremental events above.
const completed = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_combined",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_a",
name: "read_file",
arguments: { path: "/tmp/a" },
},
{
type: "function_call",
call_id: "call_b",
name: "write_file",
arguments: { path: "/tmp/b", content: "hello" },
},
],
usage: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
},
state
);
// response.completed should return a single chunk (not array of chunks)
// because both call_ids were already seen — no synthesis needed.
assert.ok(!Array.isArray(completed), "should NOT return array — no synthesis for seen call_ids");
assert.equal(
completed.choices[0].finish_reason,
"tool_calls",
"finish_reason should still be tool_calls"
);
assert.equal(completed.usage.prompt_tokens, 10);
assert.equal(completed.usage.completion_tokens, 5);
// toolCallIndex should be 2 (two tool calls were processed via incremental events)
assert.equal(state.toolCallIndex, 2, "toolCallIndex should reflect both incremental tool calls");
});