Skip to content

feat: support multimodal (image) tool results (#363)#666

Merged
AlemTuzlak merged 6 commits into
mainfrom
fix/363-multimodal-tool-results
Jun 1, 2026
Merged

feat: support multimodal (image) tool results (#363)#666
AlemTuzlak merged 6 commits into
mainfrom
fix/363-multimodal-tool-results

Conversation

@AlemTuzlak
Copy link
Copy Markdown
Contributor

@AlemTuzlak AlemTuzlak commented May 30, 2026

Problem

Tool results were always coerced to a string via JSON.stringify() before reaching a provider adapter, making it impossible to return multimodal content (e.g. an image) from a tool — even though the OpenAI Responses, Anthropic, and Gemini APIs all support multimodal tool/function outputs. This blocks use cases like returning a screenshot from a tool so the model can see it.

Closes #363.

Solution

  • Structural, opt-in detection (isContentPartArray / normalizeToolResult in @tanstack/ai): a tool that returns a non-empty array whose every element is a valid ContentPart is passed through unchanged; strings and all other return values serialize exactly as before, so there are no breaking changes.
  • ToolResultPart.content widened to string | Array<ContentPart> (in both @tanstack/ai and @tanstack/ai-client).
  • Adapters convert the parts into each provider's native multimodal tool-output format:
    • OpenAI Responses → function_call_output.output (input_image / input_text)
    • Anthropic → tool_result content blocks (also fixes a latent bug where non-string tool content became '')
    • Gemini → functionResponse.parts (inlineData / fileData), text → response.content
    • Chat Completions path (Groq, Ollama, Grok, OpenRouter chat) keeps the documented stringify fallback — that API has no multimodal tool message.
  • AG-UI compliance: TOOL_CALL_RESULT.content / TOOL_CALL_END.result stream events remain string-only per spec; the multimodal array travels on the tool message itself.

No SDK upgrades required — the installed openai / @anthropic-ai/sdk / @google/genai versions already type multimodal tool outputs.

Testing

  • Unit: detection predicate truth table; per-adapter conversion tests (OpenAI/Anthropic/Gemini); a regression test on the real server-tool execution path (buildToolResultChunks) asserting ContentPart[] is preserved as an array to the adapter while plain objects still stringify.
  • E2E: a deterministic wire-assertion spec proving the OpenAI Responses adapter sends a structured input_image in function_call_output; Anthropic/Gemini covered end-to-end + by unit tests (aimock's journal normalizes away multimodal tool content).
  • Live demo: a new examples/ts-react-chat /image-tool-repro route — a server tool returns an image of a secret number; the model now reads it back correctly (it could not before).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tools can return structured multimodal results (text + images/media); adapters send multimodal outputs natively when supported, with string fallbacks where required.
  • Examples

    • Added a reproducible image example plus a one‑click UI repro page that shows the reference image, lets you run the repro, and displays a pass/fail verdict.
  • Tests

    • Added unit and e2e tests verifying multimodal handling and wire-format behavior across providers.

@AlemTuzlak AlemTuzlak requested a review from a team as a code owner May 30, 2026 21:12
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 30, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ce3f034-ab48-4e20-9b34-4738456cbdf3

📥 Commits

Reviewing files that changed from the base of the PR and between 430fca2 and eeb8acf.

⛔ Files ignored due to path filters (1)
  • examples/ts-react-chat/public/repro-secret.png is excluded by !**/*.png
📒 Files selected for processing (30)
  • .changeset/multimodal-tool-results.md
  • examples/ts-react-chat/scripts/make-repro-image.mjs
  • examples/ts-react-chat/src/lib/image-tool-repro.ts
  • examples/ts-react-chat/src/routeTree.gen.ts
  • examples/ts-react-chat/src/routes/api.image-tool-repro.ts
  • examples/ts-react-chat/src/routes/image-tool-repro.tsx
  • packages/ai-anthropic/src/adapters/text.ts
  • packages/ai-anthropic/tests/tool-result-multimodal.test.ts
  • packages/ai-client/src/devtools.ts
  • packages/ai-client/src/types.ts
  • packages/ai-code-mode/models-eval/metrics.ts
  • packages/ai-gemini/src/adapters/text.ts
  • packages/ai-gemini/tests/tool-result-multimodal.test.ts
  • packages/ai/src/activities/chat/index.ts
  • packages/ai/src/activities/chat/messages.ts
  • packages/ai/src/activities/chat/stream/message-updaters.ts
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/src/activities/chat/tools/tool-calls.ts
  • packages/ai/src/index.ts
  • packages/ai/src/types.ts
  • packages/ai/src/utilities/ag-ui-wire.ts
  • packages/ai/src/utilities/tool-result.ts
  • packages/ai/tests/multimodal-tool-result.test.ts
  • packages/ai/tests/tool-result.test.ts
  • packages/openai-base/src/adapters/chat-completions-text.ts
  • packages/openai-base/src/adapters/responses-text.ts
  • packages/openai-base/tests/responses-text.test.ts
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.multimodal-tool-result-wire.ts
  • testing/e2e/tests/multimodal-tool-result-wire.spec.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/openai-base/src/adapters/chat-completions-text.ts
  • examples/ts-react-chat/src/routeTree.gen.ts
  • testing/e2e/src/routeTree.gen.ts
🚧 Files skipped from review as they are similar to previous changes (25)
  • packages/ai-client/src/devtools.ts
  • packages/ai/src/activities/chat/stream/message-updaters.ts
  • packages/ai-client/src/types.ts
  • .changeset/multimodal-tool-results.md
  • packages/ai/src/index.ts
  • packages/ai-code-mode/models-eval/metrics.ts
  • packages/openai-base/tests/responses-text.test.ts
  • packages/ai-anthropic/src/adapters/text.ts
  • examples/ts-react-chat/src/lib/image-tool-repro.ts
  • packages/ai/tests/multimodal-tool-result.test.ts
  • packages/ai/tests/tool-result.test.ts
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai-gemini/src/adapters/text.ts
  • testing/e2e/src/routes/api.multimodal-tool-result-wire.ts
  • packages/ai/src/activities/chat/messages.ts
  • examples/ts-react-chat/scripts/make-repro-image.mjs
  • testing/e2e/tests/multimodal-tool-result-wire.spec.ts
  • packages/ai-gemini/tests/tool-result-multimodal.test.ts
  • packages/openai-base/src/adapters/responses-text.ts
  • packages/ai/src/activities/chat/tools/tool-calls.ts
  • packages/ai/src/types.ts
  • examples/ts-react-chat/src/routes/image-tool-repro.tsx
  • examples/ts-react-chat/src/routes/api.image-tool-repro.ts
  • packages/ai/src/utilities/tool-result.ts
  • packages/ai-anthropic/tests/tool-result-multimodal.test.ts

📝 Walkthrough

Walkthrough

Enables tools to return structured multimodal results (Array), adds runtime guards and normalizeToolResult, preserves arrays through tool execution and streaming, adapts OpenAI/Anthropic/Gemini adapters to produce native multimodal payloads, adds tests and an example repro, and keeps AG‑UI stream events string-only.

Changes

Multimodal Tool Results Support

Layer / File(s) Summary
Type system expansion and normalization utilities
packages/ai-client/src/types.ts, packages/ai/src/types.ts, packages/ai/src/utilities/tool-result.ts, packages/ai/tests/tool-result.test.ts
Widened tool-result types to accept `string
Tool execution and stream message processing
packages/ai/src/activities/chat/tools/tool-calls.ts, packages/ai/src/activities/chat/stream/processor.ts, packages/ai/src/activities/chat/stream/message-updaters.ts, packages/ai/src/activities/chat/messages.ts
Tool execution and stream processing now call normalizeToolResult() and accept/preserve ContentPart[] in tool-result parts instead of always stringifying.
Chat activity integration and AG-UI wire serialization
packages/ai/src/activities/chat/index.ts, packages/ai/src/utilities/ag-ui-wire.ts, packages/ai/src/index.ts
Chat flow preserves structured arrays in internal messages and emits string-only wire/event content (string passthrough or JSON.stringify) for AG‑UI. Helpers re-exported.
OpenAI Responses and Chat Completions adapters
packages/openai-base/src/adapters/responses-text.ts, packages/openai-base/src/adapters/chat-completions-text.ts, packages/openai-base/tests/responses-text.test.ts
Responses adapter maps ContentPart[] to function_call_output items; Chat Completions path documents stringification fallback. Test validates input_text/input_image mapping.
Anthropic adapter multimodal support
packages/ai-anthropic/src/adapters/text.ts, packages/ai-anthropic/tests/tool-result-multimodal.test.ts
Anthropic adapter converts ContentPart[] into tool_result content blocks (text and base64 image mapping). Test verifies payload transformation and mocked SDK usage.
Gemini adapter multimodal support
packages/ai-gemini/src/adapters/text.ts, packages/ai-gemini/tests/tool-result-multimodal.test.ts
Gemini adapter concatenates text parts into response.content and converts media parts into parts (inlineData/fileData). Tests cover inline images, URL media, and string fallback.
Core utility and integration tests
packages/ai/tests/tool-result.test.ts, packages/ai/tests/multimodal-tool-result.test.ts
Unit tests for guards/normalization and integration tests ensuring structured arrays are preserved through tool execution while wire events carry stringified content.
Reproducible image tool example
examples/ts-react-chat/scripts/make-repro-image.mjs, examples/ts-react-chat/src/lib/image-tool-repro.ts, examples/ts-react-chat/src/routes/api.image-tool-repro.ts, examples/ts-react-chat/src/routes/image-tool-repro.tsx, examples/ts-react-chat/src/routeTree.gen.ts
Adds a PNG generator script, example tool returning inline base64 image + text, server route and UI route demonstrating detection of embedded secret.
Generated route tree updates
testing/e2e/src/routeTree.gen.ts, examples/ts-react-chat/src/routeTree.gen.ts
Register new UI/API routes for example and E2E wire verification in generated route trees.
End-to-end wire format verification
testing/e2e/src/routes/api.multimodal-tool-result-wire.ts, testing/e2e/tests/multimodal-tool-result-wire.spec.ts
E2E endpoint and tests exercise provider adapters; OpenAI test inspects Aimock journal to assert structured array content on the wire; Anthropic/Gemini verify serialization completes.
Client-side devtools and metrics
packages/ai-client/src/devtools.ts, packages/ai-code-mode/models-eval/metrics.ts
Devtools hydrate tool outputs by using arrays directly when present; metrics serialization coerces non-string multimodal content to JSON for consistent downstream parsing.
Changeset and generated updates
.changeset/multimodal-tool-results.md
Documents opt-in structural detection, adapter-specific conversions, and AG‑UI string-only event constraints.

Sequence Diagram

sequenceDiagram
  participant User
  participant Chat as ChatActivity
  participant Tool as ToolExecution
  participant Stream as StreamProcessor
  participant Adapter as ProviderAdapter
  participant Model as LLM
  User->>Chat: request triggering a tool
  Chat->>Tool: execute tool()
  Tool-->>Chat: returns string or Array<ContentPart>
  Chat->>Chat: normalizeToolResult(result)
  Chat->>Stream: addToolResult(normalized)
  Stream->>Chat: emit TOOL_CALL_RESULT (wireContent string)
  Chat->>Adapter: emit tool-role ModelMessage (structured array preserved)
  Adapter->>Model: send provider-specific multimodal payload
  Model->>Chat: model response
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Possibly related PRs:

    • TanStack/ai#632: touches devtools/tool-output pipeline and may overlap with fixture/hydration changes.
  • Suggested reviewers:

    • tombeckenham

"🐰 I stitched bytes with a careful paw,

Kept text and image both without a flaw,
Tools may show what the model can draw,
Now outputs hop true — hooray, hurrah!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: support multimodal (image) tool results (#363)' is clear, concise, and directly addresses the main change—enabling tools to return multimodal content instead of always stringifying results.
Description check ✅ Passed The PR description is comprehensive and complete, clearly outlining the problem, solution details, adapter-specific changes, AG-UI compliance, and testing strategy; it aligns well with the required template structure.
Linked Issues check ✅ Passed The PR successfully addresses all objectives from issue #363: it allows tool message content to be string or Array, preserves arrays through tool execution, implements multimodal conversion in OpenAI/Anthropic/Gemini adapters, and maintains backward compatibility.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the stated objective of supporting multimodal tool results. The addition of tooling (image generation script, repro demo), test files, and utility functions all serve the multimodal feature implementation without introducing unrelated changes.

✏️ 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/363-multimodal-tool-results

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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 May 30, 2026

🚀 Changeset Version Preview

12 package(s) bumped directly, 18 bumped as dependents.

🟥 Major bumps

Package Version Reason
@tanstack/ai-anthropic 0.11.2 → 1.0.0 Changeset
@tanstack/ai-code-mode 0.1.24 → 1.0.0 Changeset
@tanstack/ai-code-mode-skills 0.1.24 → 1.0.0 Changeset
@tanstack/ai-gemini 0.12.1 → 1.0.0 Changeset
@tanstack/ai-preact 0.7.1 → 1.0.0 Changeset
@tanstack/ai-react 0.13.1 → 1.0.0 Changeset
@tanstack/ai-solid 0.11.1 → 1.0.0 Changeset
@tanstack/ai-svelte 0.11.1 → 1.0.0 Changeset
@tanstack/ai-vue 0.11.1 → 1.0.0 Changeset
@tanstack/openai-base 0.4.1 → 1.0.0 Changeset
@tanstack/ai-elevenlabs 0.2.14 → 1.0.0 Dependent
@tanstack/ai-event-client 0.4.2 → 1.0.0 Dependent
@tanstack/ai-fal 0.7.17 → 1.0.0 Dependent
@tanstack/ai-grok 0.9.1 → 1.0.0 Dependent
@tanstack/ai-groq 0.2.8 → 1.0.0 Dependent
@tanstack/ai-isolate-node 0.1.24 → 1.0.0 Dependent
@tanstack/ai-isolate-quickjs 0.1.24 → 1.0.0 Dependent
@tanstack/ai-ollama 0.6.23 → 1.0.0 Dependent
@tanstack/ai-openai 0.10.4 → 1.0.0 Dependent
@tanstack/ai-openrouter 0.10.0 → 1.0.0 Dependent
@tanstack/ai-react-ui 0.8.4 → 1.0.0 Dependent
@tanstack/ai-solid-ui 0.7.4 → 1.0.0 Dependent

🟨 Minor bumps

Package Version Reason
@tanstack/ai 0.23.1 → 0.24.0 Changeset
@tanstack/ai-client 0.14.1 → 0.15.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/ai-devtools-core 0.4.2 → 0.4.3 Dependent
@tanstack/ai-isolate-cloudflare 0.2.15 → 0.2.16 Dependent
@tanstack/ai-vue-ui 0.2.8 → 0.2.9 Dependent
@tanstack/preact-ai-devtools 0.1.45 → 0.1.46 Dependent
@tanstack/react-ai-devtools 0.2.45 → 0.2.46 Dependent
@tanstack/solid-ai-devtools 0.2.45 → 0.2.46 Dependent

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented May 30, 2026

View your CI Pipeline Execution ↗ for commit 8936790

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-06-01 06:21:47 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 30, 2026

Open in StackBlitz

@tanstack/ai

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

@tanstack/ai-anthropic

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

@tanstack/ai-client

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

@tanstack/ai-code-mode

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

@tanstack/ai-code-mode-skills

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

@tanstack/ai-devtools-core

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

@tanstack/ai-elevenlabs

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

@tanstack/ai-event-client

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

@tanstack/ai-fal

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

@tanstack/ai-gemini

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

@tanstack/ai-grok

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

@tanstack/ai-groq

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

@tanstack/ai-isolate-cloudflare

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

@tanstack/ai-isolate-node

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

@tanstack/ai-isolate-quickjs

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

@tanstack/ai-ollama

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

@tanstack/ai-openai

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

@tanstack/ai-openrouter

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

@tanstack/ai-preact

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

@tanstack/ai-react

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

@tanstack/ai-react-ui

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

@tanstack/ai-solid

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

@tanstack/ai-solid-ui

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

@tanstack/ai-svelte

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

@tanstack/ai-utils

npm i https://pkg.pr.new/@tanstack/ai-utils@666

@tanstack/ai-vue

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

@tanstack/ai-vue-ui

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

@tanstack/openai-base

npm i https://pkg.pr.new/@tanstack/openai-base@666

@tanstack/preact-ai-devtools

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

@tanstack/react-ai-devtools

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

@tanstack/solid-ai-devtools

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

commit: 8936790

@AlemTuzlak AlemTuzlak requested a review from tombeckenham May 30, 2026 21:16
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ai/tests/tool-result.test.ts (1)

1-68: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Move this unit test alongside the source module.

This is a unit test for ../src/utilities/tool-result, but it is placed in packages/ai/tests/ instead of next to the source file. Please colocate it with the source module per repo rule.

As per coding guidelines, "Place unit tests in *.test.ts files alongside source files".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/tests/tool-result.test.ts` around lines 1 - 68, Tests for
utilities/tool-result (testing isContentPart, isContentPartArray,
normalizeToolResult) are misplaced in the tests directory; move the
tool-result.test.ts file to be colocated with the source module file that
exports those functions (the utilities/tool-result module), update its imports
to use the local relative path (e.g., import from './tool-result' instead of the
cross-package path), and run the test suite to ensure imports still resolve and
no path references remain to the old tests location.
packages/ai/src/activities/chat/tools/tool-calls.ts (1)

226-247: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep TOOL_CALL_END.result stringified here.

normalizeToolResult(result) can return ContentPart[], but executeTools() now yields that value directly as ToolCallEndEvent.result. This class is public and documented as emitting TOOL_CALL_END for client visibility, so direct ToolCallManager consumers would now see spec-invalid non-string wire payloads. Preserve the array on the returned role: 'tool' message, but serialize the event field before yielding it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/chat/tools/tool-calls.ts` around lines 226 - 247,
The TOOL_CALL_END event is yielding a non-string result when
normalizeToolResult(result) returns ContentPart[]; update executeTools() so that
before emitting the TOOL_CALL_END (the object cast as ToolCallEndEvent) you
ensure toolResultContent is a string—e.g. if normalizeToolResult(...) returns an
array or non-string, JSON.stringify it (preserve the original array on the role:
'tool' message only) so ToolCallEndEvent.result is always a serialized string;
adjust the catch branch and the branch for missing execute similarly to
guarantee TOOL_CALL_END.result is a string.
🧹 Nitpick comments (5)
packages/ai-anthropic/tests/tool-result-multimodal.test.ts (1)

1-4: ⚡ Quick win

Move this unit test alongside the source adapter file.

This new unit test is under packages/ai-anthropic/tests/, but the repository rule requires colocated *.test.ts files next to source.

As per coding guidelines **/*.test.ts: Place unit tests in *.test.ts files alongside source files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-anthropic/tests/tool-result-multimodal.test.ts` around lines 1 -
4, Move the test file from
packages/ai-anthropic/tests/tool-result-multimodal.test.ts to be colocated with
the source adapter (next to ../src/adapters/text), so it lives alongside
AnthropicTextAdapter; update the import paths in the test to use relative paths
from the new location (ensure imports like AnthropicTextAdapter and any test
helpers or '`@tanstack/ai`' remain correct) and remove the separate tests/
directory usage to comply with the repository rule that *.test.ts files sit next
to their source.
packages/ai-gemini/tests/tool-result-multimodal.test.ts (1)

1-4: ⚡ Quick win

Place this unit test next to the Gemini adapter source file.

The new test is in packages/ai-gemini/tests/, but the project guideline requires *.test.ts unit tests to live alongside the implementation.

As per coding guidelines **/*.test.ts: Place unit tests in *.test.ts files alongside source files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-gemini/tests/tool-result-multimodal.test.ts` around lines 1 - 4,
The unit test file tool-result-multimodal.test.ts must be moved from the tests
folder to live alongside the Gemini adapter implementation and its imports
updated accordingly; move the test next to the GeminiTextAdapter source file,
update the import of GeminiTextAdapter (and any other local imports) to use the
correct relative path from the adapter directory (e.g., change
'../src/adapters/text' to the local './text' or appropriate relative import),
and run the test suite to ensure imports resolve and module scope matches
project test-location guidelines.
examples/ts-react-chat/src/routes/api.image-tool-repro.ts (1)

32-34: 💤 Low value

HTTP 499 is a non-standard status code.

Status code 499 is nginx-specific for "Client Closed Request" and not part of the HTTP standard. While it conveys intent, standard codes like 408 Request Timeout or 499 from RFC 7231 Appendix B would be more portable across different HTTP clients and proxies.

Alternative: use standard 408
-return new Response(null, { status: 499 })
+return new Response(null, { status: 408 })

Also applies to: 61-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/ts-react-chat/src/routes/api.image-tool-repro.ts` around lines 32 -
34, The code returns a non-standard HTTP status 499 when request.signal.aborted
(see the check using request.signal.aborted and the Response construction new
Response(null, { status: 499 })); replace this with a standard status such as
408 (Request Timeout) for portability and update all other occurrences of the
same pattern (the second instance around the other Response(null, { status: 499
}) use) so both places return 408 (or another chosen standard status) instead of
499.
testing/e2e/src/routes/api.multimodal-tool-result-wire.ts (1)

70-78: 💤 Low value

Unconventional: HTTP 200 returned on error.

The error handler returns status 200 with {ok: false, error: "..."} rather than a 4xx/5xx status. While this is non-standard REST practice, it's acceptable for a test-only endpoint where the goal is to distinguish adapter serialization crashes (caught here) from network/HTTP errors (which would return non-200 status codes naturally).

For production endpoints, prefer standard HTTP status codes (e.g., 500 for server errors, 400 for bad requests).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/src/routes/api.multimodal-tool-result-wire.ts` around lines 70 -
78, The catch block in the route handler in api.multimodal-tool-result-wire.ts
currently returns HTTP 200 on error; change the response to an appropriate error
status (e.g., 500 for server errors) so the Response created in the catch uses {
status: 500, headers: { 'Content-Type': 'application/json' } } instead of 200
and still returns the JSON payload with ok: false and the error message; locate
the catch handling code in this file (the try/catch surrounding the handler
logic) and update the status value accordingly.
examples/ts-react-chat/scripts/make-repro-image.mjs (1)

12-13: ⚡ Quick win

Manual synchronization risk between SECRET constants.

The comment requires manual sync between this script's SECRET and REPRO_SECRET in src/lib/image-tool-repro.ts. If they drift, the test verdict logic will break but the error won't be immediately obvious.

Consider one of these approaches:

  • Export REPRO_SECRET from a shared .mjs module that both files import
  • Add a build-time verification script that parses both files and asserts equality
  • Move the secret to an environment variable or config file
Example: shared constant module

Create src/lib/repro-secret.mjs:

export const REPRO_SECRET = '473'

Then import in both places:

+import { REPRO_SECRET } from '../src/lib/repro-secret.mjs'
-const SECRET = '473'
+const SECRET = REPRO_SECRET
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/ts-react-chat/scripts/make-repro-image.mjs` around lines 12 - 13,
The SECRET value in make-repro-image.mjs is manually duplicated from
REPRO_SECRET in src/lib/image-tool-repro.ts; extract the secret into a single
shared source (e.g., create a small module that exports REPRO_SECRET or use an
environment/config variable) and update both places to import/read that single
source; specifically, replace the inline SECRET constant in make-repro-image.mjs
and the inline REPRO_SECRET usage in image-tool-repro.ts to reference the shared
export (or process.env variable) so they cannot drift, and add a small
runtime/build assertion that the imported value is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ai/src/activities/chat/messages.ts`:
- Around line 341-344: The conversion path is stripping multimodal tool outputs
by always running tool message content through getTextContent; update
modelMessageToUIMessage (and modelMessagesToUIMessages which delegates to it) to
detect when a ModelMessage has role === 'tool' and content is already a
ContentPart[] (or Array.isArray(msg.content)) and in that case preserve and pass
the ContentPart[] through unchanged instead of calling getTextContent; ensure
normalizeToolResult still produces ContentPart[] where used and that the
UIMessage produced for tool role carries the original array so images/audio/docs
are not lost on persist/rehydrate.

---

Outside diff comments:
In `@packages/ai/src/activities/chat/tools/tool-calls.ts`:
- Around line 226-247: The TOOL_CALL_END event is yielding a non-string result
when normalizeToolResult(result) returns ContentPart[]; update executeTools() so
that before emitting the TOOL_CALL_END (the object cast as ToolCallEndEvent) you
ensure toolResultContent is a string—e.g. if normalizeToolResult(...) returns an
array or non-string, JSON.stringify it (preserve the original array on the role:
'tool' message only) so ToolCallEndEvent.result is always a serialized string;
adjust the catch branch and the branch for missing execute similarly to
guarantee TOOL_CALL_END.result is a string.

In `@packages/ai/tests/tool-result.test.ts`:
- Around line 1-68: Tests for utilities/tool-result (testing isContentPart,
isContentPartArray, normalizeToolResult) are misplaced in the tests directory;
move the tool-result.test.ts file to be colocated with the source module file
that exports those functions (the utilities/tool-result module), update its
imports to use the local relative path (e.g., import from './tool-result'
instead of the cross-package path), and run the test suite to ensure imports
still resolve and no path references remain to the old tests location.

---

Nitpick comments:
In `@examples/ts-react-chat/scripts/make-repro-image.mjs`:
- Around line 12-13: The SECRET value in make-repro-image.mjs is manually
duplicated from REPRO_SECRET in src/lib/image-tool-repro.ts; extract the secret
into a single shared source (e.g., create a small module that exports
REPRO_SECRET or use an environment/config variable) and update both places to
import/read that single source; specifically, replace the inline SECRET constant
in make-repro-image.mjs and the inline REPRO_SECRET usage in image-tool-repro.ts
to reference the shared export (or process.env variable) so they cannot drift,
and add a small runtime/build assertion that the imported value is present.

In `@examples/ts-react-chat/src/routes/api.image-tool-repro.ts`:
- Around line 32-34: The code returns a non-standard HTTP status 499 when
request.signal.aborted (see the check using request.signal.aborted and the
Response construction new Response(null, { status: 499 })); replace this with a
standard status such as 408 (Request Timeout) for portability and update all
other occurrences of the same pattern (the second instance around the other
Response(null, { status: 499 }) use) so both places return 408 (or another
chosen standard status) instead of 499.

In `@packages/ai-anthropic/tests/tool-result-multimodal.test.ts`:
- Around line 1-4: Move the test file from
packages/ai-anthropic/tests/tool-result-multimodal.test.ts to be colocated with
the source adapter (next to ../src/adapters/text), so it lives alongside
AnthropicTextAdapter; update the import paths in the test to use relative paths
from the new location (ensure imports like AnthropicTextAdapter and any test
helpers or '`@tanstack/ai`' remain correct) and remove the separate tests/
directory usage to comply with the repository rule that *.test.ts files sit next
to their source.

In `@packages/ai-gemini/tests/tool-result-multimodal.test.ts`:
- Around line 1-4: The unit test file tool-result-multimodal.test.ts must be
moved from the tests folder to live alongside the Gemini adapter implementation
and its imports updated accordingly; move the test next to the GeminiTextAdapter
source file, update the import of GeminiTextAdapter (and any other local
imports) to use the correct relative path from the adapter directory (e.g.,
change '../src/adapters/text' to the local './text' or appropriate relative
import), and run the test suite to ensure imports resolve and module scope
matches project test-location guidelines.

In `@testing/e2e/src/routes/api.multimodal-tool-result-wire.ts`:
- Around line 70-78: The catch block in the route handler in
api.multimodal-tool-result-wire.ts currently returns HTTP 200 on error; change
the response to an appropriate error status (e.g., 500 for server errors) so the
Response created in the catch uses { status: 500, headers: { 'Content-Type':
'application/json' } } instead of 200 and still returns the JSON payload with
ok: false and the error message; locate the catch handling code in this file
(the try/catch surrounding the handler logic) and update the status value
accordingly.
🪄 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: 4086152f-7a09-429e-8af0-82d6619e137a

📥 Commits

Reviewing files that changed from the base of the PR and between bdadc4b and 459ec90.

⛔ Files ignored due to path filters (1)
  • examples/ts-react-chat/public/repro-secret.png is excluded by !**/*.png
📒 Files selected for processing (29)
  • .changeset/multimodal-tool-results.md
  • examples/ts-react-chat/scripts/make-repro-image.mjs
  • examples/ts-react-chat/src/lib/image-tool-repro.ts
  • examples/ts-react-chat/src/routeTree.gen.ts
  • examples/ts-react-chat/src/routes/api.image-tool-repro.ts
  • examples/ts-react-chat/src/routes/image-tool-repro.tsx
  • packages/ai-anthropic/src/adapters/text.ts
  • packages/ai-anthropic/tests/tool-result-multimodal.test.ts
  • packages/ai-client/src/devtools.ts
  • packages/ai-client/src/types.ts
  • packages/ai-gemini/src/adapters/text.ts
  • packages/ai-gemini/tests/tool-result-multimodal.test.ts
  • packages/ai/src/activities/chat/index.ts
  • packages/ai/src/activities/chat/messages.ts
  • packages/ai/src/activities/chat/stream/message-updaters.ts
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/src/activities/chat/tools/tool-calls.ts
  • packages/ai/src/index.ts
  • packages/ai/src/types.ts
  • packages/ai/src/utilities/ag-ui-wire.ts
  • packages/ai/src/utilities/tool-result.ts
  • packages/ai/tests/multimodal-tool-result.test.ts
  • packages/ai/tests/tool-result.test.ts
  • packages/openai-base/src/adapters/chat-completions-text.ts
  • packages/openai-base/src/adapters/responses-text.ts
  • packages/openai-base/tests/responses-text.test.ts
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.multimodal-tool-result-wire.ts
  • testing/e2e/tests/multimodal-tool-result-wire.spec.ts

Comment on lines 341 to 344
messageList.push({
role: 'tool',
content: JSON.stringify(part.output),
content: normalizeToolResult(part.output),
toolCallId: part.id,
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 | ⚡ Quick win

Reverse conversion still strips multimodal tool outputs.

This path can now emit role: 'tool' messages with ContentPart[], but modelMessageToUIMessage() and modelMessagesToUIMessages() below still run tool-message content through getTextContent(...). Any persisted, replayed, or rehydrated ModelMessage[] will therefore lose image/audio/document tool results on the way back to UIMessage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/chat/messages.ts` around lines 341 - 344, The
conversion path is stripping multimodal tool outputs by always running tool
message content through getTextContent; update modelMessageToUIMessage (and
modelMessagesToUIMessages which delegates to it) to detect when a ModelMessage
has role === 'tool' and content is already a ContentPart[] (or
Array.isArray(msg.content)) and in that case preserve and pass the ContentPart[]
through unchanged instead of calling getTextContent; ensure normalizeToolResult
still produces ContentPart[] where used and that the UIMessage produced for tool
role carries the original array so images/audio/docs are not lost on
persist/rehydrate.

Detect when a tool returns an Array<ContentPart> and pass it through to the
adapter instead of JSON.stringify-ing it, so multimodal tool outputs (e.g. an
image) can reach the model. Detection is structural (isContentPartArray) and
opt-in by shape; strings and all other return values serialize exactly as
before. AG-UI stream events stay string-only per spec — the array travels on
the tool ModelMessage.
ai-client redeclares ToolResultPart; widen its content to string | Array<ContentPart>
to stay compatible with @tanstack/ai, and handle array content in the devtools
fixture-hydration path.
…uts (#363)

Convert an Array<ContentPart> tool result into each provider's native
multimodal tool-output format: OpenAI Responses function_call_output.output,
Anthropic tool_result content blocks, and Gemini functionResponse.parts. The
Chat Completions path keeps the documented stringify fallback (its API has no
multimodal tool message).
Add a deterministic wire-assertion spec proving the OpenAI Responses adapter
sends a structured input_image in function_call_output (Anthropic/Gemini
end-to-end + unit-covered). Add the /image-tool-repro example: a server tool
returns an image of a secret number; the model now reads it back.
…in metrics

ToolResultPart.content was widened to string | Array<ContentPart> for
multimodal tool results, breaking the string-typed lookup in metrics.ts.
Coerce non-string content to a serialized form so the type check passes
while preserving the existing execute_typescript JSON-parse behavior.
@tombeckenham tombeckenham force-pushed the fix/363-multimodal-tool-results branch from 430fca2 to eeb8acf Compare June 1, 2026 02:38
Copy link
Copy Markdown
Contributor

@tombeckenham tombeckenham left a comment

Choose a reason for hiding this comment

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

Good to go. Nice improvement

@AlemTuzlak AlemTuzlak merged commit c1ae8b9 into main Jun 1, 2026
10 checks passed
@AlemTuzlak AlemTuzlak deleted the fix/363-multimodal-tool-results branch June 1, 2026 06:27
@github-actions github-actions Bot mentioned this pull request Jun 1, 2026
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.

Tool results are always stringified, preventing multimodal (image) tool responses with OpenAI Responses API

3 participants