Skip to content

fix(ai, ai-openai): normalize null tool input to empty object#430

Open
AlemTuzlak wants to merge 7 commits intomainfrom
fix/null-tool-input-normalization
Open

fix(ai, ai-openai): normalize null tool input to empty object#430
AlemTuzlak wants to merge 7 commits intomainfrom
fix/null-tool-input-normalization

Conversation

@AlemTuzlak
Copy link
Copy Markdown
Contributor

@AlemTuzlak AlemTuzlak commented Apr 8, 2026

Summary

  • When a model produces a tool_use block with no input (or literal null), JSON.parse('null') returns null, which fails Zod schema validation and silently kills the agent loop
  • Normalize null/non-object parsed tool input to {} in four locations:
    • executeToolCalls(): after JSON.parse of arguments string
    • ToolCallManager.completeToolCall(): before JSON.stringify of event input
    • ToolCallManager.executeTools(): replace fragile argsString === 'null' string comparison with robust type check
    • OpenAI adapter: in TOOL_CALL_END emission (matching existing Anthropic adapter fix)
  • The Anthropic adapter already had this fix; this PR extends it to all downstream code paths and the OpenAI adapter

Fixes #265

Test plan

  • New test file with 5 cases covering null, empty, and valid arguments for both executeToolCalls and ToolCallManager
  • All 636 @tanstack/ai tests pass
  • All 127 @tanstack/ai-openai tests pass

Summary by CodeRabbit

  • Bug Fixes

    • Normalize tool-call inputs so null or other non-object parsed values become {}; validate parsed function-call arguments are objects before use.
  • Tests

    • Added unit tests and an E2E scenario/fixture that verify null/empty-string/valid-object argument parsing and end-to-end tool-call normalization.
  • Chores

    • Added a patch release note documenting the null→empty-object normalization fix.

When a model produces a tool_use block with no input (or literal null),
JSON.parse('null') returns null, which fails Zod schema validation and
silently kills the agent loop.

Normalize null/non-object parsed tool input to {} in four locations:
- executeToolCalls(): after JSON.parse of arguments string
- ToolCallManager.completeToolCall(): before JSON.stringify of event input
- ToolCallManager.executeTools(): replace fragile string comparison
- OpenAI adapter: in TOOL_CALL_END emission (matching existing Anthropic fix)

Fixes #265
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c477d39c-9159-4609-bfef-567b464b8756

📥 Commits

Reviewing files that changed from the base of the PR and between ae76a69 and a00a66a.

📒 Files selected for processing (1)
  • testing/e2e/tests/tools-test/null-tool-input.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • testing/e2e/tests/tools-test/null-tool-input.spec.ts

📝 Walkthrough

Walkthrough

Normalize parsed tool-call arguments so any non-object or null JSON result becomes {} across adapters and core tool execution paths; add unit and E2E tests, fixtures, and a changeset documenting the patch.

Changes

Cohort / File(s) Summary
OpenAI Adapter
packages/typescript/ai-openai/src/adapters/text.ts
When handling response.function_call_arguments.done, only assign parsed JSON if it's a non-null object; otherwise set arguments to {}.
Gemini & Ollama Adapters
packages/typescript/ai-gemini/src/adapters/text.ts, packages/typescript/ai-ollama/src/adapters/text.ts
Hardened parsing for tool-call arguments: validate JSON.parse result is a non-null object, else normalize to {} before emitting TOOL_CALL_END.
Core Tool Handling
packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
Normalize tool inputs so parsed values that are null or non-objects become {} before stringifying/parsing and prior to schema validation in both ToolCallManager.executeTools() and executeToolCalls() paths.
Unit Tests
packages/typescript/ai/tests/tool-calls-null-input.test.ts
Added tests asserting normalization of 'null', '', and valid JSON object arguments via executeToolCalls and ToolCallManager.completeToolCall.
E2E Fixtures & Tools
testing/e2e/fixtures/tools-test/null-tool-input.json, testing/e2e/src/lib/tools-test-tools.ts
Added E2E fixture for null-argument tool-call scenario and a server-side check_status tool plus scenario registration.
E2E Test
testing/e2e/tests/tools-test/null-tool-input.spec.ts
New Playwright spec validating model-emitted null tool arguments are normalized, tool executes, and agent loop continues.
Changeset
.changeset/fix-null-tool-input-normalization.md
Added changeset documenting the patch: "normalize null tool input to empty object" for relevant packages.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I found a JSON hole so small,
I poked and nudged till numbers stall;
null became a cozy {} embrace,
Agents woke and ran the race.
Hooray—no more stalled-hoppity trace! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: normalizing null tool input to empty objects in the ai and ai-openai packages.
Description check ✅ Passed The pull request description provides a comprehensive summary of changes, implementation details across four locations, test coverage, and verification results matching the template requirements.
Linked Issues check ✅ Passed The pull request successfully addresses all core objectives from issue #265: normalizes null/non-object parsed tool input to {} in executeToolCalls, ToolCallManager.completeToolCall, ToolCallManager.executeTools, and OpenAI adapter TOOL_CALL_END emission, with comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the null tool input normalization fix across adapters (OpenAI, Gemini, Ollama), core tool handling, and comprehensive test coverage (unit, E2E, fixtures) with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/null-tool-input-normalization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

🚀 Changeset Version Preview

4 package(s) bumped directly, 23 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/ai 0.10.2 → 0.10.3 Changeset
@tanstack/ai-gemini 0.8.7 → 0.8.8 Changeset
@tanstack/ai-ollama 0.6.5 → 0.6.6 Changeset
@tanstack/ai-openai 0.7.4 → 0.7.5 Changeset
@tanstack/ai-client 0.7.9 → 0.7.10 Dependent
@tanstack/ai-code-mode 0.1.2 → 0.1.3 Dependent
@tanstack/ai-code-mode-models-eval 0.0.5 → 0.0.6 Dependent
@tanstack/ai-code-mode-skills 0.1.2 → 0.1.3 Dependent
@tanstack/ai-devtools-core 0.3.19 → 0.3.20 Dependent
@tanstack/ai-event-client 0.2.2 → 0.2.3 Dependent
@tanstack/ai-fal 0.6.12 → 0.6.13 Dependent
@tanstack/ai-isolate-cloudflare 0.1.2 → 0.1.3 Dependent
@tanstack/ai-isolate-node 0.1.2 → 0.1.3 Dependent
@tanstack/ai-isolate-quickjs 0.1.2 → 0.1.3 Dependent
@tanstack/ai-openrouter 0.7.4 → 0.7.5 Dependent
@tanstack/ai-preact 0.6.14 → 0.6.15 Dependent
@tanstack/ai-react 0.7.10 → 0.7.11 Dependent
@tanstack/ai-solid 0.6.14 → 0.6.15 Dependent
@tanstack/ai-svelte 0.6.14 → 0.6.15 Dependent
@tanstack/ai-vue 0.6.14 → 0.6.15 Dependent
@tanstack/ai-vue-ui 0.1.25 → 0.1.26 Dependent
@tanstack/preact-ai-devtools 0.1.23 → 0.1.24 Dependent
@tanstack/react-ai-devtools 0.2.23 → 0.2.24 Dependent
@tanstack/solid-ai-devtools 0.2.23 → 0.2.24 Dependent
ts-svelte-chat 0.1.31 → 0.1.32 Dependent
ts-vue-chat 0.1.31 → 0.1.32 Dependent
vanilla-chat 0.0.29 → 0.0.30 Dependent

@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 8, 2026

View your CI Pipeline Execution ↗ for commit a00a66a

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 8s View ↗
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 3s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-15 10:11:03 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 8, 2026

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@430

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@430

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@430

@tanstack/ai-code-mode

npm i https://pkg.pr.new/@tanstack/ai-code-mode@430

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/@tanstack/ai-code-mode-skills@430

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@430

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@430

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@430

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@430

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@430

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@430

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@430

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-isolate-cloudflare@430

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/@tanstack/ai-isolate-node@430

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs@430

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@430

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@430

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@430

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@430

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@430

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@430

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@430

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@430

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@430

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@430

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@430

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@430

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@430

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@430

commit: a00a66a

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/typescript/ai/tests/tool-calls-null-input.test.ts`:
- Around line 108-136: The tests fail to compile because the ToolCallManager
constructor currently accepts only one parameter (tools: ReadonlyArray<Tool>)
but tests call new ToolCallManager(..., mockFinishedEvent); update the
ToolCallManager constructor signature to accept an optional second parameter
(e.g., finishedEvent or initialFinishedEvent) with the same type as
mockFinishedEvent, adjust the constructor implementation to handle when that
second argument is provided (store or process it the same way tests expect), and
ensure all usages and exports of ToolCallManager are updated to the new
signature so the tests compiling calls to ToolCallManager(...,
mockFinishedEvent) succeed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be0d9dc8-a6e1-48a3-81fe-d6e79d0c38d0

📥 Commits

Reviewing files that changed from the base of the PR and between 4afaa76 and 4c4cfe8.

📒 Files selected for processing (3)
  • packages/typescript/ai-openai/src/adapters/text.ts
  • packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
  • packages/typescript/ai/tests/tool-calls-null-input.test.ts

Comment on lines +108 to +136
describe('ToolCallManager.completeToolCall', () => {
it('should normalize null input to empty object', () => {
const manager = new ToolCallManager([], mockFinishedEvent)

// Register a tool call
manager.addToolCallStartEvent({
type: 'TOOL_CALL_START',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
index: 0,
})

// Complete with null input (simulating Anthropic empty tool_use)
manager.completeToolCall({
type: 'TOOL_CALL_END',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
input: null as unknown,
})

const toolCalls = manager.getToolCalls()
expect(toolCalls).toHaveLength(1)
// Should be "{}" not "null"
expect(toolCalls[0]!.function.arguments).toBe('{}')
})
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify ToolCallManager constructor signature
ast-grep --pattern 'class ToolCallManager {
  $$$
  constructor($PARAMS) {
    $$$
  }
  $$$
}'

Repository: TanStack/ai

Length of output: 17852


🏁 Script executed:

cat -n packages/typescript/ai/tests/tool-calls-null-input.test.ts

Repository: TanStack/ai

Length of output: 5661


Constructor signature mismatch — tests will not compile.

ToolCallManager constructor accepts only one parameter (tools: ReadonlyArray<Tool>), but the tests at lines 110 and 139 pass a second argument (mockFinishedEvent). This causes TypeScript compilation errors.

🐛 Proposed fix
  describe('ToolCallManager.completeToolCall', () => {
    it('should normalize null input to empty object', () => {
-      const manager = new ToolCallManager([], mockFinishedEvent)
+      const manager = new ToolCallManager([])

      // Register a tool call
      manager.addToolCallStartEvent({
    it('should preserve valid object input', () => {
-      const manager = new ToolCallManager([], mockFinishedEvent)
+      const manager = new ToolCallManager([])

      manager.addToolCallStartEvent({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('ToolCallManager.completeToolCall', () => {
it('should normalize null input to empty object', () => {
const manager = new ToolCallManager([], mockFinishedEvent)
// Register a tool call
manager.addToolCallStartEvent({
type: 'TOOL_CALL_START',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
index: 0,
})
// Complete with null input (simulating Anthropic empty tool_use)
manager.completeToolCall({
type: 'TOOL_CALL_END',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
input: null as unknown,
})
const toolCalls = manager.getToolCalls()
expect(toolCalls).toHaveLength(1)
// Should be "{}" not "null"
expect(toolCalls[0]!.function.arguments).toBe('{}')
})
describe('ToolCallManager.completeToolCall', () => {
it('should normalize null input to empty object', () => {
const manager = new ToolCallManager([])
// Register a tool call
manager.addToolCallStartEvent({
type: 'TOOL_CALL_START',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
index: 0,
})
// Complete with null input (simulating Anthropic empty tool_use)
manager.completeToolCall({
type: 'TOOL_CALL_END',
toolCallId: 'tc-1',
toolName: 'test_tool',
model: 'test',
timestamp: Date.now(),
input: null as unknown,
})
const toolCalls = manager.getToolCalls()
expect(toolCalls).toHaveLength(1)
// Should be "{}" not "null"
expect(toolCalls[0]!.function.arguments).toBe('{}')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/tests/tool-calls-null-input.test.ts` around lines 108
- 136, The tests fail to compile because the ToolCallManager constructor
currently accepts only one parameter (tools: ReadonlyArray<Tool>) but tests call
new ToolCallManager(..., mockFinishedEvent); update the ToolCallManager
constructor signature to accept an optional second parameter (e.g.,
finishedEvent or initialFinishedEvent) with the same type as mockFinishedEvent,
adjust the constructor implementation to handle when that second argument is
provided (store or process it the same way tests expect), and ensure all usages
and exports of ToolCallManager are updated to the new signature so the tests
compiling calls to ToolCallManager(..., mockFinishedEvent) succeed.

AlemTuzlak and others added 4 commits April 8, 2026 13:50
…sion test

Extend the null tool input normalization to the Gemini and Ollama
adapters (both were missing the guard in TOOL_CALL_END emission).

Add an e2e regression test for issue #265 that verifies the full flow:
aimock returns a tool call with "null" arguments → adapter normalizes
null → {} → tool executes successfully → agent loop continues →
follow-up text response is received.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/typescript/ai-ollama/src/adapters/text.ts`:
- Around line 251-256: The catch fallback currently assigns parsedInput =
actualToolCall.function.arguments which may be a string or undefined; change the
catch to normalize parsedInput to an object by using an object-shaped fallback
(e.g., {} or Object(actualToolCall.function.arguments) guarded to ensure it's an
object) so TOOL_CALL_END.input always remains an object; update the catch block
that reads argsStr/parsedInput (referencing parsedInput, argsStr, and
actualToolCall.function.arguments) to enforce parsedInput is an object and
default to {} when it is not.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 70f9e3f9-e9cd-4c9b-a766-513197bd8be4

📥 Commits

Reviewing files that changed from the base of the PR and between 80fb196 and ae76a69.

📒 Files selected for processing (6)
  • .changeset/fix-null-tool-input-normalization.md
  • packages/typescript/ai-gemini/src/adapters/text.ts
  • packages/typescript/ai-ollama/src/adapters/text.ts
  • testing/e2e/fixtures/tools-test/null-tool-input.json
  • testing/e2e/src/lib/tools-test-tools.ts
  • testing/e2e/tests/tools-test/null-tool-input.spec.ts
✅ Files skipped from review due to trivial changes (2)
  • testing/e2e/fixtures/tools-test/null-tool-input.json
  • .changeset/fix-null-tool-input-normalization.md

Comment on lines 251 to 256
try {
parsedInput = JSON.parse(argsStr)
const parsed = JSON.parse(argsStr)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = actualToolCall.function.arguments
}
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.

⚠️ Potential issue | 🟠 Major

Catch fallback should keep TOOL_CALL_END.input object-shaped

On Line 255, parsedInput = actualToolCall.function.arguments can emit a string/undefined after parse failure, which can still break downstream tool-input validation. Normalize catch fallback to {} to keep the invariant consistent.

Suggested fix
         try {
           const parsed = JSON.parse(argsStr)
           parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
         } catch {
-          parsedInput = actualToolCall.function.arguments
+          parsedInput = {}
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
parsedInput = JSON.parse(argsStr)
const parsed = JSON.parse(argsStr)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = actualToolCall.function.arguments
}
try {
const parsed = JSON.parse(argsStr)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = {}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-ollama/src/adapters/text.ts` around lines 251 - 256,
The catch fallback currently assigns parsedInput =
actualToolCall.function.arguments which may be a string or undefined; change the
catch to normalize parsedInput to an object by using an object-shaped fallback
(e.g., {} or Object(actualToolCall.function.arguments) guarded to ensure it's an
object) so TOOL_CALL_END.input always remains an object; update the catch block
that reads argsStr/parsedInput (referencing parsedInput, argsStr, and
actualToolCall.function.arguments) to enforce parsedInput is an object and
default to {} when it is not.

The selectScenario + waitForTestComplete calls can be slow on CI
cold-start. Increase timeout from 15s to 30s to avoid flaky failures.
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.

Anthropic adapter passes null tool input when model produces empty tool_use block, causing agent loop to stall

1 participant