diff --git a/.changeset/fix-anthropic-multi-turn-tool-calls.md b/.changeset/fix-anthropic-multi-turn-tool-calls.md
new file mode 100644
index 000000000..34553ecec
--- /dev/null
+++ b/.changeset/fix-anthropic-multi-turn-tool-calls.md
@@ -0,0 +1,35 @@
+---
+'@tanstack/ai': patch
+'@tanstack/ai-client': patch
+'@tanstack/ai-anthropic': patch
+'@tanstack/ai-gemini': patch
+---
+
+fix(ai, ai-client, ai-anthropic, ai-gemini): fix multi-turn conversations failing after tool calls
+
+**Core (@tanstack/ai):**
+
+- Lazy assistant message creation: `StreamProcessor` now defers creating the assistant message until the first content-bearing chunk arrives (text, tool call, thinking, or error), eliminating empty `parts: []` messages from appearing during auto-continuation when the model returns no content
+- Add `prepareAssistantMessage()` (lazy) alongside deprecated `startAssistantMessage()` (eager, backwards-compatible)
+- Add `getCurrentAssistantMessageId()` to check if a message was created
+- **Rewrite `uiMessageToModelMessages()` to preserve part ordering**: the function now walks parts sequentially instead of separating by type, producing correctly interleaved assistant/tool messages (text1 + toolCall1 → toolResult1 → text2 + toolCall2 → toolResult2) instead of concatenating all text and batching all tool calls. This fixes multi-round tool flows where the model would see garbled conversation history and re-call tools unnecessarily.
+- Deduplicate tool result messages: when a client tool has both a `tool-result` part and a `tool-call` part with `output`, only one `role: 'tool'` message is emitted per tool call ID
+
+**Client (@tanstack/ai-client):**
+
+- Update `ChatClient.processStream()` to use lazy assistant message creation, preventing UI flicker from empty messages being created then removed
+
+**Anthropic:**
+
+- Fix consecutive user-role messages violating Anthropic's alternating role requirement by merging them in `formatMessages`
+- Deduplicate `tool_result` blocks with the same `tool_use_id`
+- Filter out empty assistant messages from conversation history
+- Suppress duplicate `RUN_FINISHED` event from `message_stop` when `message_delta` already emitted one
+- Fix `TEXT_MESSAGE_END` incorrectly emitting for `tool_use` content blocks
+- Add Claude Opus 4.6 model support with adaptive thinking and effort parameter
+
+**Gemini:**
+
+- Fix consecutive user-role messages violating Gemini's alternating role requirement by merging them in `formatMessages`
+- Deduplicate `functionResponse` parts with the same name (tool call ID)
+- Filter out empty model messages from conversation history
diff --git a/.changeset/tighten-adapter-contract.md b/.changeset/tighten-adapter-contract.md
new file mode 100644
index 000000000..78a7af3f5
--- /dev/null
+++ b/.changeset/tighten-adapter-contract.md
@@ -0,0 +1,25 @@
+---
+'@tanstack/ai': minor
+'@tanstack/ai-anthropic': minor
+'@tanstack/ai-gemini': minor
+---
+
+Tighten the AG-UI adapter contract and simplify the core stream processor.
+
+**Breaking type changes:**
+
+- `TextMessageContentEvent.delta` is now required (was optional)
+- `StepFinishedEvent.delta` is now required (was optional)
+
+All first-party adapters already sent `delta` on every event, so this is a type-level enforcement of existing behavior. Community adapters that follow the reference implementations will not need code changes.
+
+**Core processor simplifications:**
+
+- `TEXT_MESSAGE_START` now resets text segment state, replacing heuristic overlap detection
+- `TOOL_CALL_END` is now the authoritative signal for tool call input completion
+- Removed delta/content fallback logic, whitespace-only message cleanup, and finish-reason conflict arbitration from the processor
+
+**Adapter fixes:**
+
+- Gemini: filter whitespace-only text parts, fix STEP_FINISHED content accumulation, emit fresh TEXT_MESSAGE_START after tool calls
+- Anthropic: emit fresh TEXT_MESSAGE_START after tool_use blocks for proper text segmentation
diff --git a/.gitignore b/.gitignore
index 91d2a1c58..340546608 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,4 +56,6 @@ test-traces
 
 # Playwright
 playwright-report
-test-results
\ No newline at end of file
+test-results
+
+STATUS_*.md
\ No newline at end of file
diff --git a/examples/ts-group-chat/package.json b/examples/ts-group-chat/package.json
index 90137ede0..3d91b522c 100644
--- a/examples/ts-group-chat/package.json
+++ b/examples/ts-group-chat/package.json
@@ -14,11 +14,11 @@
     "@tanstack/ai-client": "workspace:*",
     "@tanstack/ai-react": "workspace:*",
     "@tanstack/react-devtools": "^0.8.2",
-    "@tanstack/react-router": "^1.141.1",
-    "@tanstack/react-router-devtools": "^1.139.7",
-    "@tanstack/react-router-ssr-query": "^1.139.7",
-    "@tanstack/react-start": "^1.141.1",
-    "@tanstack/router-plugin": "^1.139.7",
+    "@tanstack/react-router": "^1.158.4",
+    "@tanstack/react-router-devtools": "^1.158.4",
+    "@tanstack/react-router-ssr-query": "^1.158.4",
+    "@tanstack/react-start": "^1.159.0",
+    "@tanstack/router-plugin": "^1.158.4",
     "capnweb": "^0.1.0",
     "react": "^19.2.3",
     "react-dom": "^19.2.3",
diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json
index f58f54bda..d74d35af3 100644
--- a/examples/ts-react-chat/package.json
+++ b/examples/ts-react-chat/package.json
@@ -20,14 +20,14 @@
     "@tanstack/ai-openrouter": "workspace:*",
     "@tanstack/ai-react": "workspace:*",
     "@tanstack/ai-react-ui": "workspace:*",
-    "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
+    "@tanstack/nitro-v2-vite-plugin": "^1.154.7",
     "@tanstack/react-devtools": "^0.8.2",
-    "@tanstack/react-router": "^1.141.1",
-    "@tanstack/react-router-devtools": "^1.139.7",
-    "@tanstack/react-router-ssr-query": "^1.139.7",
-    "@tanstack/react-start": "^1.141.1",
+    "@tanstack/react-router": "^1.158.4",
+    "@tanstack/react-router-devtools": "^1.158.4",
+    "@tanstack/react-router-ssr-query": "^1.158.4",
+    "@tanstack/react-start": "^1.159.0",
     "@tanstack/react-store": "^0.8.0",
-    "@tanstack/router-plugin": "^1.139.7",
+    "@tanstack/router-plugin": "^1.158.4",
     "@tanstack/store": "^0.8.0",
     "highlight.js": "^11.11.1",
     "lucide-react": "^0.561.0",
diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts
index 627a240cf..f7099165f 100644
--- a/examples/ts-react-chat/src/routeTree.gen.ts
+++ b/examples/ts-react-chat/src/routeTree.gen.ts
@@ -39,7 +39,7 @@ export interface FileRoutesByFullPath {
   '/': typeof IndexRoute
   '/api/tanchat': typeof ApiTanchatRoute
   '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute
-  '/example/guitars': typeof ExampleGuitarsIndexRoute
+  '/example/guitars/': typeof ExampleGuitarsIndexRoute
 }
 export interface FileRoutesByTo {
   '/': typeof IndexRoute
@@ -60,7 +60,7 @@ export interface FileRouteTypes {
     | '/'
     | '/api/tanchat'
     | '/example/guitars/$guitarId'
-    | '/example/guitars'
+    | '/example/guitars/'
   fileRoutesByTo: FileRoutesByTo
   to: '/' | '/api/tanchat' | '/example/guitars/$guitarId' | '/example/guitars'
   id:
@@ -97,7 +97,7 @@ declare module '@tanstack/react-router' {
     '/example/guitars/': {
       id: '/example/guitars/'
       path: '/example/guitars'
-      fullPath: '/example/guitars'
+      fullPath: '/example/guitars/'
       preLoaderRoute: typeof ExampleGuitarsIndexRouteImport
       parentRoute: typeof rootRouteImport
     }
diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json
index 3a9ea9e8d..bfe5ec050 100644
--- a/examples/ts-solid-chat/package.json
+++ b/examples/ts-solid-chat/package.json
@@ -19,8 +19,8 @@
     "@tanstack/ai-openai": "workspace:*",
     "@tanstack/ai-solid": "workspace:*",
     "@tanstack/ai-solid-ui": "workspace:*",
-    "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
-    "@tanstack/router-plugin": "^1.139.7",
+    "@tanstack/nitro-v2-vite-plugin": "^1.154.7",
+    "@tanstack/router-plugin": "^1.158.4",
     "@tanstack/solid-ai-devtools": "workspace:*",
     "@tanstack/solid-devtools": "^0.7.15",
     "@tanstack/solid-router": "^1.139.10",
diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts
index a4f41bbbe..235d9f5b5 100644
--- a/packages/typescript/ai-anthropic/src/adapters/text.ts
+++ b/packages/typescript/ai-anthropic/src/adapters/text.ts
@@ -247,6 +247,7 @@ export class AnthropicTextAdapter<
       const validKeys: Array<keyof InternalTextProviderOptions> = [
         'container',
         'context_management',
+        'effort',
         'mcp_servers',
         'service_tier',
         'stop_sequences',
@@ -450,7 +451,74 @@ export class AnthropicTextAdapter<
       })
     }
 
-    return formattedMessages
+    // Post-process: Anthropic requires strictly alternating user/assistant roles.
+    // Tool results are sent as role:'user' messages, which can create consecutive
+    // user messages when followed by a new user message. Merge them.
+    return this.mergeConsecutiveSameRoleMessages(formattedMessages)
+  }
+
+  /**
+   * Merge consecutive messages of the same role into a single message.
+   * Anthropic's API requires strictly alternating user/assistant roles.
+   * Tool results are wrapped as role:'user' messages, which can collide
+   * with actual user messages in multi-turn conversations.
+   *
+   * Also filters out empty assistant messages (e.g., from a previous failed request).
+   */
+  private mergeConsecutiveSameRoleMessages(
+    messages: InternalTextProviderOptions['messages'],
+  ): InternalTextProviderOptions['messages'] {
+    const merged: InternalTextProviderOptions['messages'] = []
+
+    for (const msg of messages) {
+      // Skip empty assistant messages (no content or empty string)
+      if (msg.role === 'assistant') {
+        const hasContent = Array.isArray(msg.content)
+          ? msg.content.length > 0
+          : typeof msg.content === 'string' && msg.content.length > 0
+        if (!hasContent) {
+          continue
+        }
+      }
+
+      const prev = merged[merged.length - 1]
+      if (prev && prev.role === msg.role) {
+        // Normalize both contents to arrays and concatenate
+        const prevBlocks = Array.isArray(prev.content)
+          ? prev.content
+          : typeof prev.content === 'string' && prev.content
+            ? [{ type: 'text' as const, text: prev.content }]
+            : []
+        const msgBlocks = Array.isArray(msg.content)
+          ? msg.content
+          : typeof msg.content === 'string' && msg.content
+            ? [{ type: 'text' as const, text: msg.content }]
+            : []
+        prev.content = [...prevBlocks, ...msgBlocks]
+      } else {
+        merged.push({ ...msg })
+      }
+    }
+
+    // De-duplicate tool_result blocks with the same tool_use_id.
+    // This can happen when the core layer generates tool results from both
+    // the tool-result part and the tool-call part's output field.
+    for (const msg of merged) {
+      if (Array.isArray(msg.content)) {
+        const seenToolResultIds = new Set<string>()
+        msg.content = msg.content.filter((block: any) => {
+          if (block.type === 'tool_result' && block.tool_use_id) {
+            if (seenToolResultIds.has(block.tool_use_id)) {
+              return false // Remove duplicate
+            }
+            seenToolResultIds.add(block.tool_use_id)
+          }
+          return true
+        })
+      }
+    }
+
+    return merged
   }
 
   private async *processAnthropicStream(
@@ -473,6 +541,9 @@ export class AnthropicTextAdapter<
     let stepId: string | null = null
     let hasEmittedRunStarted = false
     let hasEmittedTextMessageStart = false
+    let hasEmittedRunFinished = false
+    // Track current content block type for proper content_block_stop handling
+    let currentBlockType: string | null = null
 
     try {
       for await (const event of stream) {
@@ -488,6 +559,7 @@ export class AnthropicTextAdapter<
         }
 
         if (event.type === 'content_block_start') {
+          currentBlockType = event.content_block.type
           if (event.content_block.type === 'tool_use') {
             currentToolIndex++
             toolCallsMap.set(currentToolIndex, {
@@ -572,59 +644,71 @@ export class AnthropicTextAdapter<
             }
           }
         } else if (event.type === 'content_block_stop') {
-          const existing = toolCallsMap.get(currentToolIndex)
-          if (existing) {
-            // If tool call wasn't started yet (no args), start it now
-            if (!existing.started) {
-              existing.started = true
+          if (currentBlockType === 'tool_use') {
+            const existing = toolCallsMap.get(currentToolIndex)
+            if (existing) {
+              // If tool call wasn't started yet (no args), start it now
+              if (!existing.started) {
+                existing.started = true
+                yield {
+                  type: 'TOOL_CALL_START',
+                  toolCallId: existing.id,
+                  toolName: existing.name,
+                  model,
+                  timestamp,
+                  index: currentToolIndex,
+                }
+              }
+
+              // Emit TOOL_CALL_END
+              let parsedInput: unknown = {}
+              try {
+                const parsed = existing.input ? JSON.parse(existing.input) : {}
+                parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
+              } catch {
+                parsedInput = {}
+              }
+
               yield {
-                type: 'TOOL_CALL_START',
+                type: 'TOOL_CALL_END',
                 toolCallId: existing.id,
                 toolName: existing.name,
                 model,
                 timestamp,
-                index: currentToolIndex,
+                input: parsedInput,
               }
-            }
 
-            // Emit TOOL_CALL_END
-            let parsedInput: unknown = {}
-            try {
-              const parsed = existing.input ? JSON.parse(existing.input) : {}
-              parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
-            } catch {
-              parsedInput = {}
+              // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls
+              hasEmittedTextMessageStart = false
             }
-
-            yield {
-              type: 'TOOL_CALL_END',
-              toolCallId: existing.id,
-              toolName: existing.name,
-              model,
-              timestamp,
-              input: parsedInput,
+          } else {
+            // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks)
+            if (hasEmittedTextMessageStart && accumulatedContent) {
+              yield {
+                type: 'TEXT_MESSAGE_END',
+                messageId,
+                model,
+                timestamp,
+              }
             }
           }
-
-          // Emit TEXT_MESSAGE_END if we had text content
-          if (hasEmittedTextMessageStart && accumulatedContent) {
+          currentBlockType = null
+        } else if (event.type === 'message_stop') {
+          // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one.
+          // message_delta carries the real stop_reason (tool_use, end_turn, etc.),
+          // while message_stop is just a completion signal.
+          if (!hasEmittedRunFinished) {
             yield {
-              type: 'TEXT_MESSAGE_END',
-              messageId,
+              type: 'RUN_FINISHED',
+              runId,
               model,
               timestamp,
+              finishReason: 'stop',
             }
           }
-        } else if (event.type === 'message_stop') {
-          yield {
-            type: 'RUN_FINISHED',
-            runId,
-            model,
-            timestamp,
-            finishReason: 'stop',
-          }
         } else if (event.type === 'message_delta') {
           if (event.delta.stop_reason) {
+            hasEmittedRunFinished = true
             switch (event.delta.stop_reason) {
               case 'tool_use': {
                 yield {
diff --git a/packages/typescript/ai-anthropic/src/model-meta.ts b/packages/typescript/ai-anthropic/src/model-meta.ts
index 1f642b86b..ec7ca7807 100644
--- a/packages/typescript/ai-anthropic/src/model-meta.ts
+++ b/packages/typescript/ai-anthropic/src/model-meta.ts
@@ -19,6 +19,7 @@ interface ModelMeta<
   supports: {
     input: Array<'text' | 'image' | 'audio' | 'video' | 'document'>
     extended_thinking?: boolean
+    adaptive_thinking?: boolean
     priority_tier?: boolean
   }
   context_window?: number
diff --git a/packages/typescript/ai-anthropic/src/text/text-provider-options.ts b/packages/typescript/ai-anthropic/src/text/text-provider-options.ts
index 8c4dfeccf..b26da487d 100644
--- a/packages/typescript/ai-anthropic/src/text/text-provider-options.ts
+++ b/packages/typescript/ai-anthropic/src/text/text-provider-options.ts
@@ -92,6 +92,42 @@ Must be ≥1024 and less than max_tokens
       }
 }
 
+export interface AnthropicAdaptiveThinkingOptions {
+  /**
+   * Configuration for Claude's adaptive thinking (Opus 4.6+).
+   *
+   * In adaptive mode, Claude dynamically decides when and how much to think.
+   * Use the effort parameter to control thinking depth.
+   * `thinking: {type: "enabled"}` with `budget_tokens` is deprecated on Opus 4.6.
+   */
+  thinking?:
+    | {
+        type: 'adaptive'
+      }
+    | {
+        /**
+         * @deprecated Use `type: 'adaptive'` with the effort parameter on Opus 4.6+.
+         */
+        budget_tokens: number
+        type: 'enabled'
+      }
+    | {
+        type: 'disabled'
+      }
+}
+
+export interface AnthropicEffortOptions {
+  /**
+   * Controls the thinking depth for adaptive thinking mode (Opus 4.6+).
+   *
+   * - `max`: Absolute highest capability
+   * - `high`: Default - Claude will almost always think
+   * - `medium`: Balanced cost-quality
+   * - `low`: May skip thinking for simpler problems
+   */
+  effort?: 'max' | 'high' | 'medium' | 'low'
+}
+
 export interface AnthropicToolChoiceOptions {
   tool_choice?: BetaToolChoiceAny | BetaToolChoiceTool | BetaToolChoiceAuto
 }
@@ -115,7 +151,9 @@ export type ExternalTextProviderOptions = AnthropicContainerOptions &
   AnthropicStopSequencesOptions &
   AnthropicThinkingOptions &
   AnthropicToolChoiceOptions &
-  AnthropicSamplingOptions
+  AnthropicSamplingOptions &
+  Partial<AnthropicAdaptiveThinkingOptions> &
+  Partial<AnthropicEffortOptions>
 
 export interface InternalTextProviderOptions extends ExternalTextProviderOptions {
   model: string
diff --git a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
index 5e3db4348..76c50ccf2 100644
--- a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
+++ b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
@@ -183,4 +183,508 @@ describe('Anthropic adapter option mapping', () => {
       type: 'custom',
     })
   })
+
+  it('merges consecutive user messages when tool results precede a follow-up user message', async () => {
+    // This is the core multi-turn bug: after a tool call + result, the next user message
+    // creates consecutive role:'user' messages (tool_result as user + new user message).
+    // Anthropic's API requires strictly alternating user/assistant roles.
+    const mockStream = (async function* () {
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Here is a recommendation' },
+      }
+      yield {
+        type: 'content_block_stop',
+        index: 0,
+      }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'end_turn' },
+        usage: { output_tokens: 10 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    // Multi-turn: user -> assistant(tool_calls) -> tool_result -> follow-up user
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: 'What is the weather in Berlin?' },
+        {
+          role: 'assistant',
+          content: 'Let me check the weather.',
+          toolCalls: [
+            {
+              id: 'call_1',
+              type: 'function',
+              function: { name: 'lookup_weather', arguments: toolArguments },
+            },
+          ],
+        },
+        { role: 'tool', toolCallId: 'call_1', content: '{"temp":72}' },
+        { role: 'user', content: 'What about Paris?' },
+      ],
+      tools: [weatherTool],
+    })) {
+      chunks.push(chunk)
+    }
+
+    expect(mocks.betaMessagesCreate).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.betaMessagesCreate.mock.calls[0]
+
+    // The tool_result (user) and follow-up user message should be merged into one user message
+    expect(payload.messages).toEqual([
+      {
+        role: 'user',
+        content: 'What is the weather in Berlin?',
+      },
+      {
+        role: 'assistant',
+        content: [
+          { type: 'text', text: 'Let me check the weather.' },
+          {
+            type: 'tool_use',
+            id: 'call_1',
+            name: 'lookup_weather',
+            input: { location: 'Berlin' },
+          },
+        ],
+      },
+      {
+        role: 'user',
+        content: [
+          {
+            type: 'tool_result',
+            tool_use_id: 'call_1',
+            content: '{"temp":72}',
+          },
+          { type: 'text', text: 'What about Paris?' },
+        ],
+      },
+    ])
+
+    // Verify roles strictly alternate: user, assistant, user
+    const roles = payload.messages.map((m: any) => m.role)
+    for (let i = 1; i < roles.length; i++) {
+      expect(roles[i]).not.toBe(roles[i - 1])
+    }
+  })
+
+  it('merges multiple consecutive tool result messages into one user message', async () => {
+    // When multiple tools are called, each tool result becomes a role:'user' message.
+    // These must be merged into a single user message.
+    const mockStream = (async function* () {
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Here are the results' },
+      }
+      yield {
+        type: 'content_block_stop',
+        index: 0,
+      }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'end_turn' },
+        usage: { output_tokens: 5 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: 'Weather in Berlin and Paris?' },
+        {
+          role: 'assistant',
+          content: null,
+          toolCalls: [
+            {
+              id: 'call_berlin',
+              type: 'function',
+              function: {
+                name: 'lookup_weather',
+                arguments: JSON.stringify({ location: 'Berlin' }),
+              },
+            },
+            {
+              id: 'call_paris',
+              type: 'function',
+              function: {
+                name: 'lookup_weather',
+                arguments: JSON.stringify({ location: 'Paris' }),
+              },
+            },
+          ],
+        },
+        { role: 'tool', toolCallId: 'call_berlin', content: '{"temp":72}' },
+        { role: 'tool', toolCallId: 'call_paris', content: '{"temp":68}' },
+      ],
+      tools: [weatherTool],
+    })) {
+      chunks.push(chunk)
+    }
+
+    expect(mocks.betaMessagesCreate).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.betaMessagesCreate.mock.calls[0]
+
+    // Both tool results should be merged into a single user message
+    expect(payload.messages).toEqual([
+      {
+        role: 'user',
+        content: 'Weather in Berlin and Paris?',
+      },
+      {
+        role: 'assistant',
+        content: [
+          {
+            type: 'tool_use',
+            id: 'call_berlin',
+            name: 'lookup_weather',
+            input: { location: 'Berlin' },
+          },
+          {
+            type: 'tool_use',
+            id: 'call_paris',
+            name: 'lookup_weather',
+            input: { location: 'Paris' },
+          },
+        ],
+      },
+      {
+        role: 'user',
+        content: [
+          {
+            type: 'tool_result',
+            tool_use_id: 'call_berlin',
+            content: '{"temp":72}',
+          },
+          {
+            type: 'tool_result',
+            tool_use_id: 'call_paris',
+            content: '{"temp":68}',
+          },
+        ],
+      },
+    ])
+
+    // Verify roles strictly alternate
+    const roles = payload.messages.map((m: any) => m.role)
+    for (let i = 1; i < roles.length; i++) {
+      expect(roles[i]).not.toBe(roles[i - 1])
+    }
+  })
+
+  it('handles full multi-turn flow with duplicate tool results, empty assistant, and follow-up', async () => {
+    // This reproduces the exact bug scenario from the testing panel:
+    // 1. Assistant calls getGuitars + recommendGuitar (with text)
+    // 2. Tool results include duplicates (from both tool-result and tool-call output)
+    // 3. An empty assistant message exists (from the client tool round-trip)
+    // 4. User sends a follow-up message
+    // All of: duplicates, empty assistant, consecutive user messages must be handled.
+    const mockStream = (async function* () {
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Electric guitars available' },
+      }
+      yield { type: 'content_block_stop', index: 0 }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'end_turn' },
+        usage: { output_tokens: 5 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: "what's a good acoustic guitar?" },
+        {
+          role: 'assistant',
+          content: "I'll help you find a good acoustic guitar!",
+          toolCalls: [
+            {
+              id: 'toolu_getGuitars',
+              type: 'function',
+              function: { name: 'getGuitars', arguments: '' },
+            },
+            {
+              id: 'toolu_recommend',
+              type: 'function',
+              function: {
+                name: 'recommendGuitar',
+                arguments: '{"id": 7}',
+              },
+            },
+          ],
+        },
+        // Tool result from tool-result part
+        {
+          role: 'tool',
+          toolCallId: 'toolu_getGuitars',
+          content: '[{"id":7,"name":"Guitar"}]',
+        },
+        // Tool result from tool-result part
+        {
+          role: 'tool',
+          toolCallId: 'toolu_recommend',
+          content: '{"id":7}',
+        },
+        // DUPLICATE tool result from tool-call output field
+        {
+          role: 'tool',
+          toolCallId: 'toolu_recommend',
+          content: '{"id":7}',
+        },
+        // Empty assistant from client tool round-trip
+        { role: 'assistant', content: null },
+        // User follow-up
+        { role: 'user', content: "what's a good electric guitar?" },
+      ],
+      tools: [weatherTool],
+    })) {
+      chunks.push(chunk)
+    }
+
+    expect(mocks.betaMessagesCreate).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.betaMessagesCreate.mock.calls[0]
+
+    // Verify: no consecutive same-role messages, no empty assistants, no duplicate tool_results
+    const roles = payload.messages.map((m: any) => m.role)
+    for (let i = 1; i < roles.length; i++) {
+      expect(roles[i]).not.toBe(roles[i - 1])
+    }
+
+    // Should have exactly 3 messages: user, assistant, user (merged tool results + follow-up)
+    expect(payload.messages).toHaveLength(3)
+    expect(payload.messages[0].role).toBe('user')
+    expect(payload.messages[1].role).toBe('assistant')
+    expect(payload.messages[2].role).toBe('user')
+
+    // The merged user message should have tool results (de-duplicated) + follow-up text
+    const lastUserContent = payload.messages[2].content
+    expect(Array.isArray(lastUserContent)).toBe(true)
+
+    // Count tool_result blocks - should have 2 (one per tool), not 3 (no duplicate)
+    const toolResultBlocks = lastUserContent.filter(
+      (b: any) => b.type === 'tool_result',
+    )
+    expect(toolResultBlocks).toHaveLength(2)
+
+    // Should have the follow-up text
+    const textBlocks = lastUserContent.filter((b: any) => b.type === 'text')
+    expect(textBlocks).toHaveLength(1)
+    expect(textBlocks[0].text).toBe("what's a good electric guitar?")
+  })
+
+  it('filters out empty assistant messages from conversation history', async () => {
+    // An empty assistant message (from a previous failed request) should be filtered out.
+    const mockStream = (async function* () {
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Response' },
+      }
+      yield {
+        type: 'content_block_stop',
+        index: 0,
+      }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'end_turn' },
+        usage: { output_tokens: 3 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: 'Hello' },
+        { role: 'assistant', content: '' }, // Empty assistant from failed request
+        { role: 'user', content: 'Try again' },
+      ],
+    })) {
+      chunks.push(chunk)
+    }
+
+    expect(mocks.betaMessagesCreate).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.betaMessagesCreate.mock.calls[0]
+
+    // The empty assistant message should be filtered out, and consecutive
+    // user messages should be merged
+    expect(payload.messages).toEqual([
+      {
+        role: 'user',
+        content: [
+          { type: 'text', text: 'Hello' },
+          { type: 'text', text: 'Try again' },
+        ],
+      },
+    ])
+  })
+})
+
+describe('Anthropic stream processing', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('does not emit duplicate RUN_FINISHED from message_stop after message_delta', async () => {
+    // message_delta with stop_reason already emits RUN_FINISHED.
+    // message_stop should NOT emit another one.
+    const mockStream = (async function* () {
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Hello' },
+      }
+      yield {
+        type: 'content_block_stop',
+        index: 0,
+      }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'end_turn' },
+        usage: { output_tokens: 3 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [{ role: 'user', content: 'Hi' }],
+    })) {
+      chunks.push(chunk)
+    }
+
+    // Should have exactly ONE RUN_FINISHED event (from message_delta), not two
+    const runFinished = chunks.filter((c) => c.type === 'RUN_FINISHED')
+    expect(runFinished).toHaveLength(1)
+    expect(runFinished[0]).toMatchObject({
+      type: 'RUN_FINISHED',
+      finishReason: 'stop',
+    })
+  })
+
+  it('does not emit TEXT_MESSAGE_END for tool_use content blocks', async () => {
+    // When text is followed by a tool_use block, TEXT_MESSAGE_END should only
+    // fire once (for the text block), not again when the tool block stops.
+    const mockStream = (async function* () {
+      // Text block
+      yield {
+        type: 'content_block_start',
+        index: 0,
+        content_block: { type: 'text', text: '' },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 0,
+        delta: { type: 'text_delta', text: 'Let me check' },
+      }
+      yield { type: 'content_block_stop', index: 0 }
+      // Tool use block
+      yield {
+        type: 'content_block_start',
+        index: 1,
+        content_block: {
+          type: 'tool_use',
+          id: 'tool_1',
+          name: 'lookup_weather',
+        },
+      }
+      yield {
+        type: 'content_block_delta',
+        index: 1,
+        delta: {
+          type: 'input_json_delta',
+          partial_json: '{"location":"Berlin"}',
+        },
+      }
+      yield { type: 'content_block_stop', index: 1 }
+      yield {
+        type: 'message_delta',
+        delta: { stop_reason: 'tool_use' },
+        usage: { output_tokens: 10 },
+      }
+      yield { type: 'message_stop' }
+    })()
+
+    mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
+
+    const adapter = createAdapter('claude-3-7-sonnet-20250219')
+
+    const chunks: StreamChunk[] = []
+    for await (const chunk of chat({
+      adapter,
+      messages: [{ role: 'user', content: 'Weather in Berlin?' }],
+      tools: [weatherTool],
+    })) {
+      chunks.push(chunk)
+    }
+
+    // TEXT_MESSAGE_END should appear exactly once (for the text block)
+    const textMessageEnds = chunks.filter((c) => c.type === 'TEXT_MESSAGE_END')
+    expect(textMessageEnds).toHaveLength(1)
+
+    // RUN_FINISHED should appear exactly once (from message_delta with tool_use)
+    const runFinished = chunks.filter((c) => c.type === 'RUN_FINISHED')
+    expect(runFinished).toHaveLength(1)
+    expect(runFinished[0]).toMatchObject({
+      finishReason: 'tool_calls',
+    })
+  })
 })
diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts
index e95389fba..65554a447 100644
--- a/packages/typescript/ai-client/src/chat-client.ts
+++ b/packages/typescript/ai-client/src/chat-client.ts
@@ -230,31 +230,37 @@ export class ChatClient {
    */
   private async processStream(
     source: AsyncIterable<StreamChunk>,
-  ): Promise<UIMessage> {
+  ): Promise<UIMessage | null> {
     // Generate a stream ID for this streaming operation
     this.currentStreamId = this.generateUniqueId('stream')
 
-    // Start a new assistant message
-    const messageId = this.processor.startAssistantMessage()
-    this.currentMessageId = messageId
-
-    // Emit message appended event for the new assistant message
-    const assistantMessage: UIMessage = {
-      id: messageId,
-      role: 'assistant',
-      parts: [],
-      createdAt: new Date(),
-    }
-    this.events.messageAppended(
-      assistantMessage,
-      this.currentStreamId || undefined,
-    )
+    // Prepare for a new assistant message (created lazily on first content)
+    this.processor.prepareAssistantMessage()
 
     // Process each chunk
     for await (const chunk of source) {
       this.callbacksRef.current.onChunk(chunk)
       this.processor.processChunk(chunk)
 
+      // Track the message ID once the processor lazily creates it
+      if (!this.currentMessageId) {
+        const newMessageId =
+          this.processor.getCurrentAssistantMessageId() ?? null
+        if (newMessageId) {
+          this.currentMessageId = newMessageId
+          // Emit message appended event now that the assistant message exists
+          const assistantMessage = this.processor
+            .getMessages()
+            .find((m: UIMessage) => m.id === newMessageId)
+          if (assistantMessage) {
+            this.events.messageAppended(
+              assistantMessage,
+              this.currentStreamId || undefined,
+            )
+          }
+        }
+      }
+
       // Yield control back to event loop to allow UI updates
       await new Promise((resolve) => setTimeout(resolve, 0))
     }
@@ -268,24 +274,20 @@ export class ChatClient {
     // Finalize the stream
     this.processor.finalizeStream()
 
+    // Get the message ID (may be null if no content arrived)
+    const messageId = this.processor.getCurrentAssistantMessageId()
+
     // Clear the current stream and message IDs
     this.currentStreamId = null
     this.currentMessageId = null
 
-    // Return the assistant message
-    const messages = this.processor.getMessages()
-    const finalAssistantMessage = messages.find(
-      (m: UIMessage) => m.id === messageId,
-    )
+    // Return the assistant message if one was created
+    if (messageId) {
+      const messages = this.processor.getMessages()
+      return messages.find((m: UIMessage) => m.id === messageId) || null
+    }
 
-    return (
-      finalAssistantMessage || {
-        id: messageId,
-        role: 'assistant',
-        parts: [],
-        createdAt: new Date(),
-      }
-    )
+    return null
   }
 
   /**
diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts
index 031298c18..dd27209a2 100644
--- a/packages/typescript/ai-gemini/src/adapters/text.ts
+++ b/packages/typescript/ai-gemini/src/adapters/text.ts
@@ -16,6 +16,7 @@ import type {
   StructuredOutputResult,
 } from '@tanstack/ai/adapters'
 import type {
+  Content,
   GenerateContentParameters,
   GenerateContentResponse,
   GoogleGenAI,
@@ -194,6 +195,7 @@ export class GeminiTextAdapter<
   ): AsyncIterable<StreamChunk> {
     const timestamp = Date.now()
     let accumulatedContent = ''
+    let accumulatedThinking = ''
     const toolCallMap = new Map<
       string,
       { name: string; args: string; index: number; started: boolean }
@@ -239,15 +241,17 @@ export class GeminiTextAdapter<
                 }
               }
 
+              accumulatedThinking += part.text
               yield {
                 type: 'STEP_FINISHED',
                 stepId: stepId || generateId(this.name),
                 model,
                 timestamp,
                 delta: part.text,
-                content: part.text,
+                content: accumulatedThinking,
               }
-            } else {
+            } else if (part.text.trim()) {
+              // Skip whitespace-only text parts (e.g. "\n" during auto-continuation)
               // Emit TEXT_MESSAGE_START on first text content
               if (!hasEmittedTextMessageStart) {
                 hasEmittedTextMessageStart = true
@@ -332,7 +336,8 @@ export class GeminiTextAdapter<
             }
           }
         }
-      } else if (chunk.data) {
+      } else if (chunk.data && chunk.data.trim()) {
+        // Skip whitespace-only data (e.g. "\n" during auto-continuation)
         // Emit TEXT_MESSAGE_START on first text content
         if (!hasEmittedTextMessageStart) {
           hasEmittedTextMessageStart = true
@@ -434,6 +439,11 @@ export class GeminiTextAdapter<
           }
         }
 
+        // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls
+        if (toolCallMap.size > 0) {
+          hasEmittedTextMessageStart = false
+        }
+
         if (finishReason === FinishReason.MAX_TOKENS) {
           yield {
             type: 'RUN_ERROR',
@@ -520,7 +530,7 @@ export class GeminiTextAdapter<
   private formatMessages(
     messages: Array<ModelMessage>,
   ): GenerateContentParameters['contents'] {
-    return messages.map((msg) => {
+    const formatted = messages.map((msg) => {
       const role: 'user' | 'model' = msg.role === 'assistant' ? 'model' : 'user'
       const parts: Array<Part> = []
 
@@ -574,6 +584,67 @@ export class GeminiTextAdapter<
         parts: parts.length > 0 ? parts : [{ text: '' }],
       }
     })
+
+    // Post-process: Gemini requires strictly alternating user/model roles.
+    // Tool results are mapped to role:'user', which can create consecutive
+    // user messages when followed by a new user message. Merge them.
+    return this.mergeConsecutiveSameRoleMessages(formatted)
+  }
+
+  /**
+   * Merge consecutive messages of the same role into a single message.
+   * Gemini's API requires strictly alternating user/model roles.
+   * Tool results are mapped to role:'user', which can collide with actual
+   * user messages in multi-turn conversations.
+   *
+   * Also filters out empty model messages (e.g., from a previous failed request)
+   * and deduplicates functionResponse parts with the same name (tool call ID).
+   */
+  private mergeConsecutiveSameRoleMessages(
+    messages: Array<Content>,
+  ): Array<Content> {
+    const merged: Array<Content> = []
+
+    for (const msg of messages) {
+      const parts = msg.parts || []
+
+      // Skip empty model messages (no parts or only empty text)
+      if (msg.role === 'model') {
+        const hasContent =
+          parts.length > 0 &&
+          !parts.every(
+            (p) => 'text' in p && (p as { text: string }).text === '',
+          )
+        if (!hasContent) {
+          continue
+        }
+      }
+
+      const prev = merged[merged.length - 1]
+      if (prev && prev.role === msg.role) {
+        // Merge parts arrays
+        prev.parts = [...(prev.parts || []), ...parts]
+      } else {
+        merged.push({ ...msg, parts: [...parts] })
+      }
+    }
+
+    // Deduplicate functionResponse parts with the same name (tool call ID)
+    for (const msg of merged) {
+      if (!msg.parts) continue
+      const seenFunctionResponseNames = new Set<string>()
+      msg.parts = msg.parts.filter((part) => {
+        if ('functionResponse' in part && part.functionResponse?.name) {
+          if (seenFunctionResponseNames.has(part.functionResponse.name)) {
+            return false
+          }
+          seenFunctionResponseNames.add(part.functionResponse.name)
+        }
+        return true
+      })
+    }
+
+    return merged
   }
 
   private mapCommonOptionsToGemini(
diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
index 9add4bdc7..58c75ceee 100644
--- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
+++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
@@ -330,6 +330,174 @@ describe('GeminiAdapter through AI', () => {
     })
   })
 
+  it('merges consecutive user messages when tool results precede a follow-up user message', async () => {
+    const streamChunks = [
+      {
+        candidates: [
+          {
+            content: {
+              parts: [{ text: 'Here is a recommendation' }],
+            },
+            finishReason: 'STOP',
+          },
+        ],
+        usageMetadata: {
+          promptTokenCount: 10,
+          candidatesTokenCount: 5,
+          totalTokenCount: 15,
+        },
+      },
+    ]
+
+    mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))
+
+    const adapter = createTextAdapter()
+
+    for await (const _ of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: 'What is the weather in Berlin?' },
+        {
+          role: 'assistant',
+          content: 'Let me check.',
+          toolCalls: [
+            {
+              id: 'call_1',
+              type: 'function',
+              function: {
+                name: 'lookup_weather',
+                arguments: '{"location":"Berlin"}',
+              },
+            },
+          ],
+        },
+        { role: 'tool', toolCallId: 'call_1', content: '{"temp":72}' },
+        { role: 'user', content: 'What about Paris?' },
+      ],
+      tools: [weatherTool],
+    })) {
+      /* consume */
+    }
+
+    expect(mocks.generateContentStreamSpy).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.generateContentStreamSpy.mock.calls[0]
+
+    // Tool result (user) and follow-up user message should be merged
+    const roles = payload.contents.map((m: any) => m.role)
+    for (let i = 1; i < roles.length; i++) {
+      expect(roles[i]).not.toBe(roles[i - 1])
+    }
+
+    // Should have 3 messages: user, model, user (merged tool result + follow-up)
+    expect(payload.contents).toHaveLength(3)
+    expect(payload.contents[0].role).toBe('user')
+    expect(payload.contents[1].role).toBe('model')
+    expect(payload.contents[2].role).toBe('user')
+
+    // Last user message should contain both functionResponse and text
+    const lastParts = payload.contents[2].parts
+    const hasFunctionResponse = lastParts.some((p: any) => p.functionResponse)
+    const hasText = lastParts.some((p: any) => p.text === 'What about Paris?')
+    expect(hasFunctionResponse).toBe(true)
+    expect(hasText).toBe(true)
+  })
+
+  it('handles full multi-turn with duplicate tool results and empty model message', async () => {
+    const streamChunks = [
+      {
+        candidates: [
+          {
+            content: {
+              parts: [{ text: 'Electric guitars available' }],
+            },
+            finishReason: 'STOP',
+          },
+        ],
+        usageMetadata: {
+          promptTokenCount: 20,
+          candidatesTokenCount: 5,
+          totalTokenCount: 25,
+        },
+      },
+    ]
+
+    mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))
+
+    const adapter = createTextAdapter()
+
+    for await (const _ of chat({
+      adapter,
+      messages: [
+        { role: 'user', content: "what's a good acoustic guitar?" },
+        {
+          role: 'assistant',
+          content: null,
+          toolCalls: [
+            {
+              id: 'call_guitars',
+              type: 'function',
+              function: { name: 'getGuitars', arguments: '' },
+            },
+            {
+              id: 'call_recommend',
+              type: 'function',
+              function: {
+                name: 'recommendGuitar',
+                arguments: '{"id":7}',
+              },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          toolCallId: 'call_guitars',
+          content: '[{"id":7,"name":"Guitar"}]',
+        },
+        {
+          role: 'tool',
+          toolCallId: 'call_recommend',
+          content: '{"id":7}',
+        },
+        // Duplicate tool result (from client tool output)
+        {
+          role: 'tool',
+          toolCallId: 'call_recommend',
+          content: '{"id":7}',
+        },
+        // Empty assistant from client tool round-trip
+        { role: 'assistant', content: null },
+        // Follow-up
+        { role: 'user', content: "what's a good electric guitar?" },
+      ],
+      tools: [weatherTool],
+    })) {
+      /* consume */
+    }
+
+    expect(mocks.generateContentStreamSpy).toHaveBeenCalledTimes(1)
+    const [payload] = mocks.generateContentStreamSpy.mock.calls[0]
+
+    // No consecutive same-role messages
+    const roles = payload.contents.map((m: any) => m.role)
+    for (let i = 1; i < roles.length; i++) {
+      expect(roles[i]).not.toBe(roles[i - 1])
+    }
+
+    // Should be 3 messages: user, model, user
+    expect(payload.contents).toHaveLength(3)
+
+    // Last user should have deduplicated functionResponses + follow-up text
+    const lastParts = payload.contents[2].parts
+    const functionResponses = lastParts.filter((p: any) => p.functionResponse)
+    // 2 unique tool call IDs, not 3 (duplicate removed)
+    expect(functionResponses).toHaveLength(2)
+
+    const textParts = lastParts.filter(
+      (p: any) => p.text === "what's a good electric guitar?",
+    )
+    expect(textParts).toHaveLength(1)
+  })
+
   it('uses summarize function with models API', async () => {
     const summaryText = 'Short and sweet.'
     mocks.generateContentSpy.mockResolvedValueOnce({
diff --git a/packages/typescript/ai/docs/chat-architecture.md b/packages/typescript/ai/docs/chat-architecture.md
new file mode 100644
index 000000000..5625a6ab5
--- /dev/null
+++ b/packages/typescript/ai/docs/chat-architecture.md
@@ -0,0 +1,587 @@
+# Chat Stream Architecture
+
+> **Canonical reference for AG-UI chunk processing.**
+> The `StreamProcessor` class cross-references sections of this document.
+> When modifying stream handling behavior, update this document first, then code.
+
+---
+
+## Table of Contents
+
+1. [System Overview](#system-overview)
+2. [Single-Shot Text Response](#single-shot-text-response)
+3. [Single-Shot Tool Call Response](#single-shot-tool-call-response)
+4. [Parallel Tool Calls (Single Shot)](#parallel-tool-calls-single-shot)
+5. [Text-Then-Tool Interleaving (Single Shot)](#text-then-tool-interleaving-single-shot)
+6. [Thinking/Reasoning Content](#thinkingreasoning-content)
+7. [Tool Results and the TOOL_CALL_END Dual Role](#tool-results-and-the-tool_call_end-dual-role)
+8. [Client Tools and Approval Flows](#client-tools-and-approval-flows)
+9. [Multi-Iteration Agent Loop](#multi-iteration-agent-loop)
+10. [Adapter Contract](#adapter-contract)
+11. [StreamProcessor Internal State](#streamprocessor-internal-state)
+12. [UIMessage Part Ordering Invariants](#uimessage-part-ordering-invariants)
+13. [Testing Strategy](#testing-strategy)
+
+---
+
+## System Overview
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│                        chat() function                       │
+│                                                              │
+│   ┌────────────┐    AG-UI events     ┌──────────────────┐    │
+│   │   Adapter  │ ──────────────────> │   TextEngine     │    │
+│   │ (provider) │                     │ (agent loop)     │    │
+│   └────────────┘                     └────────┬─────────┘    │
+│                                               │              │
+│                                       yield AG-UI events     │
+│                                               │              │
+└───────────────────────────────────────────────┼──────────────┘
+                                                │
+                                                v
+                                      ┌──────────────────┐
+                                      │ StreamProcessor  │
+                                      │ (UI state owner) │
+                                      └──────────────────┘
+                                                │
+                                                v
+                                        UIMessage[] updates
+                                       (onMessagesChange)
+```
+
+Two components consume the AG-UI event stream:
+
+- **TextEngine** (server, `chat/index.ts`) -- Orchestrates the agent loop. Accumulates text and tool calls for its own bookkeeping, decides whether to execute tools and re-invoke the adapter. Yields all events downstream.
+- **StreamProcessor** (client, `chat/stream/processor.ts`) -- The single source of truth for `UIMessage[]` state. Consumes AG-UI events and maintains the conversation as an array of `UIMessage` objects with typed parts.
+
+Both trust the adapter to emit events in the correct order. The processor does **not** attempt error recovery for out-of-order events.
+
+---
+
+## Single-Shot Text Response
+
+The simplest possible flow. The model returns text with no tool calls.
+
+### Required chunk sequence from the adapter
+
+```
+#   Event                  Required Fields                        Notes
+──  ─────────────────────  ─────────────────────────────────────  ──────────────────────────────────────────
+1   RUN_STARTED            runId                                  MUST be first event
+2   TEXT_MESSAGE_START      messageId, role: "assistant"           MUST precede any TEXT_MESSAGE_CONTENT
+3   TEXT_MESSAGE_CONTENT    messageId, delta: "Hello"              One per token; delta is the increment
+4   TEXT_MESSAGE_CONTENT    messageId, delta: " world"             Appended to previous
+5   TEXT_MESSAGE_CONTENT    messageId, delta: "!"                  ...
+6   TEXT_MESSAGE_END        messageId                              Signals text segment complete
+7   RUN_FINISHED           runId, finishReason: "stop"            MUST be last meaningful event
+```
+
+### Processor behavior, step by step
+
+| # | Chunk received | Processor action | State change |
+|---|---|---|---|
+| 1 | `RUN_STARTED` | **Ignored.** No handler. | (none) |
+| 2 | `TEXT_MESSAGE_START` | `handleTextMessageStartEvent()`: Flushes any pending text from a prior segment, then resets `currentSegmentText = ""` and `lastEmittedText = ""`. | Segment accumulators reset. |
+| 3 | `TEXT_MESSAGE_CONTENT` | `handleTextMessageContentEvent()`: Calls `ensureAssistantMessage()` (lazily creates the assistant UIMessage on first content chunk). Appends `delta` to both `currentSegmentText` and `totalTextContent`. Chunk strategy decides emission. `emitTextUpdate()` calls `updateTextPart()` which: if last part is TextPart, replaces its content; otherwise appends a new TextPart. | UIMessage created, parts: `[{ type: "text", content: "Hello" }]` |
+| 4 | `TEXT_MESSAGE_CONTENT` | Same as above. `currentSegmentText` is now `"Hello world"`. `updateTextPart()` replaces the last TextPart. | parts: `[{ type: "text", content: "Hello world" }]` |
+| 5 | `TEXT_MESSAGE_CONTENT` | Same. | parts: `[{ type: "text", content: "Hello world!" }]` |
+| 6 | `TEXT_MESSAGE_END` | **Ignored.** No handler. | (none) |
+| 7 | `RUN_FINISHED` | `handleRunFinishedEvent()`: Sets `finishReason = "stop"`, `isDone = true`. Calls `completeAllToolCalls()` (no-op, no tool calls). | Done. |
+| — | Stream ends | `finalizeStream()`: Calls `completeAllToolCalls()` (no-op). Flushes any pending text. Fires `onStreamEnd`. | Final. |
+
+### Key invariant
+
+> **`updateTextPart()` appends vs. replaces based on the last part's type.**
+> If the last part is `type: "text"`, its content is *replaced* (not appended to).
+> If the last part is anything else (tool-call, tool-result, thinking), a *new* TextPart is pushed.
+> This is how multi-segment text (text before and after tool calls) works.
+
+### Final UIMessage
+
+```typescript
+{
+  id: "msg-...",
+  role: "assistant",
+  parts: [
+    { type: "text", content: "Hello world!" }
+  ]
+}
+```
+
+---
+
+## Single-Shot Tool Call Response
+
+The model returns text AND one tool call. This is the most common source of bugs because the tool call events are interleaved with text events in a single stream.
+
+### Required chunk sequence from the adapter
+
+```
+#    Event                  Required Fields                                     Notes
+───  ─────────────────────  ──────────────────────────────────────────────────  ──────────────────────────────────────────────────
+1    RUN_STARTED            runId                                               First event
+2    TEXT_MESSAGE_START     messageId, role: "assistant"                        Before any text content
+3    TEXT_MESSAGE_CONTENT   messageId, delta: "Let me check."                   Text before tool call
+4    TEXT_MESSAGE_END       messageId                                           Text segment done
+5    TOOL_CALL_START        toolCallId: "call_1", toolName: "getWeather"        MUST have toolCallId + toolName
+6    TOOL_CALL_ARGS         toolCallId: "call_1", delta: '{"city":'             Incremental JSON string
+7    TOOL_CALL_ARGS         toolCallId: "call_1", delta: '"NYC"}'               More JSON
+8    TOOL_CALL_END          toolCallId: "call_1", toolName: "getWeather"        Signals arguments finalized
+9    RUN_FINISHED           runId, finishReason: "tool_calls"                   MUST be "tool_calls" when tools requested
+```
+
+### Critical ordering rules
+
+1. **`TOOL_CALL_START` MUST precede any `TOOL_CALL_ARGS` for the same `toolCallId`.**
+   The processor creates internal state (`InternalToolCallState`) in the `toolCalls` Map on `TOOL_CALL_START`. If `TOOL_CALL_ARGS` arrives for an unknown `toolCallId`, the args are **silently dropped** (the `existingToolCall` lookup fails).
+
+2. **`TOOL_CALL_END` MUST come after all `TOOL_CALL_ARGS` for that `toolCallId`.**
+   `TOOL_CALL_END` transitions the tool call to `input-complete` state and does a final JSON parse of accumulated arguments. Any `TOOL_CALL_ARGS` after `TOOL_CALL_END` for the same ID will still be processed (appending to arguments), but the state has already been set to `input-complete`.
+
+3. **`RUN_FINISHED` with `finishReason: "tool_calls"` MUST come last.**
+   The TextEngine uses this to decide whether to enter the tool execution phase. The StreamProcessor uses it as a signal to force-complete any tool calls still not in `input-complete` state (safety net).
+
+4. **Text events and tool call events can interleave**, but within each tool call the order MUST be: `START -> ARGS* -> END`.
+
+### Processor behavior, step by step
+
+| # | Chunk | Processor action | UIMessage parts after |
+|---|---|---|---|
+| 1 | `RUN_STARTED` | Ignored. | `[]` |
+| 2 | `TEXT_MESSAGE_START` | Resets segment accumulators. | `[]` |
+| 3 | `TEXT_MESSAGE_CONTENT` | `ensureAssistantMessage()` creates UIMessage. Appends delta. `updateTextPart()` creates TextPart. | `[text: "Let me check."]` |
+| 4 | `TEXT_MESSAGE_END` | Ignored. | `[text: "Let me check."]` |
+| 5 | `TOOL_CALL_START` | `ensureAssistantMessage()` (already exists). Creates `InternalToolCallState` in Map with `state: "awaiting-input"`. `updateToolCallPart()` appends a ToolCallPart. | `[text: "Let me check.", tool-call(call_1, awaiting-input, args: "")]` |
+| 6 | `TOOL_CALL_ARGS` | Appends delta to `arguments`. State transitions `awaiting-input -> input-streaming`. `updateToolCallPart()` updates existing ToolCallPart by ID. | `[text: "Let me check.", tool-call(call_1, input-streaming, args: '{"city":')]` |
+| 7 | `TOOL_CALL_ARGS` | Appends delta. Stays `input-streaming`. Arguments now complete. | `[text: "Let me check.", tool-call(call_1, input-streaming, args: '{"city":"NYC"}')]` |
+| 8 | `TOOL_CALL_END` | `completeToolCall()`: Sets `state: "input-complete"`. Does final JSON parse. No `result` field, so no tool-result part created. | `[text: "Let me check.", tool-call(call_1, input-complete, args: '{"city":"NYC"}')]` |
+| 9 | `RUN_FINISHED` | Sets `finishReason = "tool_calls"`. `completeAllToolCalls()`: iterates all tool calls; `call_1` already `input-complete`, so no-op. | Same as above. |
+| — | Stream ends | `finalizeStream()`: Flushes pending text (already emitted). Fires `onStreamEnd`. | Final. |
+
+### Tool call state transitions
+
+```
+TOOL_CALL_START received     →  state: "awaiting-input"      (created in Map + UIMessage)
+First TOOL_CALL_ARGS         →  state: "input-streaming"     (only if delta is non-empty)
+Subsequent TOOL_CALL_ARGS    →  state: "input-streaming"     (no change, args accumulate)
+TOOL_CALL_END received       →  state: "input-complete"      (final parse, authoritative)
+  OR
+RUN_FINISHED received        →  state: "input-complete"      (safety net via completeAllToolCalls)
+```
+
+### What the processor does NOT do at this stage
+
+- It does **not** execute the tool. That's the TextEngine's job.
+- It does **not** create a `tool-result` part. That happens later when `TOOL_CALL_END` arrives WITH a `result` field (from TextEngine after execution).
+- It does **not** validate that `toolName` matches a known tool. The processor is tool-definition-agnostic.
+
+---
+
+## Parallel Tool Calls (Single Shot)
+
+The model requests multiple tools in one response. Adapters may interleave the events.
+
+### Required chunk sequence (interleaved)
+
+```
+#    Event                  Key Fields
+───  ─────────────────────  ──────────────────────────────────────────
+1    RUN_STARTED            runId
+2    TOOL_CALL_START        toolCallId: "call_1", toolName: "getWeather", index: 0
+3    TOOL_CALL_START        toolCallId: "call_2", toolName: "getTime", index: 1
+4    TOOL_CALL_ARGS         toolCallId: "call_1", delta: '{"city":"NYC"}'
+5    TOOL_CALL_ARGS         toolCallId: "call_2", delta: '{"tz":"EST"}'
+6    TOOL_CALL_END          toolCallId: "call_1", toolName: "getWeather"
+7    TOOL_CALL_END          toolCallId: "call_2", toolName: "getTime"
+8    RUN_FINISHED           finishReason: "tool_calls"
+```
+
+### Alternative valid ordering (sequential)
+
+```
+TOOL_CALL_START  call_1
+TOOL_CALL_ARGS   call_1
+TOOL_CALL_END    call_1
+TOOL_CALL_START  call_2
+TOOL_CALL_ARGS   call_2
+TOOL_CALL_END    call_2
+RUN_FINISHED
+```
+
+Both orderings are valid. The processor tracks tool calls by `toolCallId` in a `Map`, not by position. The `index` field on `TOOL_CALL_START` is stored for reference but the Map key is always `toolCallId`.
+
+### Processor guarantees
+
+- Each `TOOL_CALL_START` creates a new entry in the Map. Duplicate `toolCallId` on a second `TOOL_CALL_START` is a no-op (the `if (!existingToolCall)` guard).
+- `TOOL_CALL_ARGS` finds its tool call by `toolCallId`. If the ID is unknown, the event is silently dropped.
+- `TOOL_CALL_END` finds its tool call by `toolCallId`. If already `input-complete`, it's a no-op (the state guard).
+- `RUN_FINISHED` with `completeAllToolCalls()` force-completes any tool call not yet `input-complete`.
+
+### Final UIMessage parts
+
+```typescript
+[
+  { type: "tool-call", id: "call_1", name: "getWeather", arguments: '{"city":"NYC"}', state: "input-complete" },
+  { type: "tool-call", id: "call_2", name: "getTime", arguments: '{"tz":"EST"}', state: "input-complete" },
+]
+```
+
+Parts are ordered by when `TOOL_CALL_START` was first received, since `updateToolCallPart()` appends new parts at the end.
+
+---
+
+## Text-Then-Tool Interleaving (Single Shot)
+
+A response with text, then a tool call, then the tool result comes back, then more text. This tests the multi-segment text logic.
+
+### Chunk sequence (first adapter call + TextEngine tool result + second adapter call)
+
+```
+── First adapter stream ──────────────────────────────────
+1    RUN_STARTED
+2    TEXT_MESSAGE_START      messageId: "m1"
+3    TEXT_MESSAGE_CONTENT    delta: "Checking weather..."
+4    TEXT_MESSAGE_END
+5    TOOL_CALL_START        toolCallId: "call_1", toolName: "getWeather"
+6    TOOL_CALL_ARGS         toolCallId: "call_1", delta: '{"city":"NYC"}'
+7    TOOL_CALL_END          toolCallId: "call_1"
+8    RUN_FINISHED           finishReason: "tool_calls"
+
+── TextEngine executes tool, yields result ──────────────
+9    TOOL_CALL_END          toolCallId: "call_1", result: '{"temp":"72F"}'
+
+── Second adapter stream ─────────────────────────────────
+10   TEXT_MESSAGE_START      messageId: "m2"
+11   TEXT_MESSAGE_CONTENT    delta: "It's 72°F in NYC."
+12   TEXT_MESSAGE_END
+13   RUN_FINISHED           finishReason: "stop"
+```
+
+### Critical: TEXT_MESSAGE_START resets the text segment
+
+At step 10, `handleTextMessageStartEvent()`:
+- Flushes any pending text from the previous segment.
+- Resets `currentSegmentText = ""` and `lastEmittedText = ""`.
+
+This means the next `TEXT_MESSAGE_CONTENT` (step 11) starts accumulating into a fresh segment. When `emitTextUpdate()` is called, `updateTextPart()` sees the last part is a `tool-result` (not text), so it **pushes a new TextPart** rather than replacing.
+
+### UIMessage parts progression
+
+```
+After step 3:  [text: "Checking weather..."]
+After step 5:  [text: "Checking weather...", tool-call(call_1, awaiting-input)]
+After step 7:  [text: "Checking weather...", tool-call(call_1, input-complete)]
+After step 9:  [text: "Checking weather...", tool-call(call_1, input-complete, output: {...}), tool-result(call_1, complete)]
+After step 11: [text: "Checking weather...", tool-call(call_1, ...), tool-result(call_1, ...), text: "It's 72°F in NYC."]
+```
+
+Note: **two separate TextParts** in the final message. This preserves the interleaving for accurate round-trip conversion to ModelMessages.
+
+---
+
+## Thinking/Reasoning Content
+
+### Chunk sequence
+
+```
+1    RUN_STARTED
+2    STEP_STARTED           stepId, stepType: "thinking"       (informational, ignored by processor)
+3    STEP_FINISHED          stepId, delta: "Let me think"
+4    STEP_FINISHED          stepId, delta: " about this..."
+5    TEXT_MESSAGE_START
+6    TEXT_MESSAGE_CONTENT    delta: "Here's my answer."
+7    TEXT_MESSAGE_END
+8    RUN_FINISHED           finishReason: "stop"
+```
+
+### Processor behavior
+
+- `STEP_STARTED` -- Ignored (no handler).
+- `STEP_FINISHED` -- `handleStepFinishedEvent()`: Calls `ensureAssistantMessage()`. Appends `delta` to `thinkingContent`. `updateThinkingPart()` either updates existing ThinkingPart or pushes a new one. Always **replaces** (not accumulates) -- the full `thinkingContent` string is set.
+
+### Final UIMessage parts
+
+```typescript
+[
+  { type: "thinking", content: "Let me think about this..." },
+  { type: "text", content: "Here's my answer." }
+]
+```
+
+---
+
+## Tool Results and the TOOL_CALL_END Dual Role
+
+`TOOL_CALL_END` serves two purposes depending on whether `result` is present:
+
+### Without `result` (from adapter)
+
+Signals that the tool call's **input arguments** are finalized.
+- Transitions state to `input-complete`.
+- Does final JSON parse of accumulated arguments.
+- If `input` field is provided, uses it as canonical parsed arguments.
+- **No tool-result part is created.**
+
+### With `result` (from TextEngine after execution)
+
+Signals that the tool has been **executed** and the result is available.
+- Still transitions state to `input-complete` (if not already).
+- Creates/updates two things:
+  1. `updateToolCallWithOutput()` -- Sets `output` on the tool-call part (for UI rendering consistency).
+  2. `updateToolResultPart()` -- Creates a `tool-result` part (for LLM conversation history).
+- The `result` field is a JSON string.
+
+### This distinction is critical
+
+Adapters emit `TOOL_CALL_END` **without** `result` -- they're signaling "arguments are done."
+The TextEngine emits `TOOL_CALL_END` **with** `result` -- it's signaling "tool was executed, here's the output."
+
+If an adapter incorrectly includes `result`, the processor will store it as a tool-result part immediately, which may cause unexpected behavior in the agent loop.
+
+---
+
+## Client Tools and Approval Flows
+
+These are handled via `CUSTOM` events emitted by the TextEngine (not the adapter).
+
+### Client tool flow
+
+```
+CUSTOM { name: "tool-input-available", data: { toolCallId, toolName, input } }
+```
+
+Processor action: Fires `onToolCall` callback. Client calls `addToolResult(toolCallId, output)`.
+
+### Approval flow
+
+```
+CUSTOM { name: "approval-requested", data: { toolCallId, toolName, input, approval: { id, needsApproval: true } } }
+```
+
+Processor action:
+1. `updateToolCallApproval()` -- Sets `state: "approval-requested"` and adds `approval` metadata to tool-call part.
+2. Fires `onApprovalRequest` callback.
+3. Client calls `addToolApprovalResponse(approvalId, true/false)`.
+4. `updateToolCallApprovalResponse()` -- Sets `state: "approval-responded"` and `approval.approved`.
+
+---
+
+## Multi-Iteration Agent Loop
+
+The TextEngine may call the adapter multiple times within a single `chat()` call (when tools are involved).
+
+### Iteration cycle
+
+```
+do {
+  processText:      Call adapter.chatStream(), yield events, accumulate tool calls
+  executeToolCalls: If finishReason == "tool_calls", execute tools, yield TOOL_CALL_END with results
+} while (agentLoopStrategy says continue)
+```
+
+### What the StreamProcessor sees
+
+The processor receives a **single flat stream** of AG-UI events from all iterations. It does not know about iterations. Each `TEXT_MESSAGE_START` resets its text segment accumulator, which is how multi-iteration text works correctly.
+
+### StreamProcessor.process() vs manual processChunk()
+
+- `process(stream)` -- Consumes the entire async iterable, calls `processChunk()` for each event, then `finalizeStream()`. Used for simple consumption.
+- `processChunk(chunk)` -- Processes a single event. Used when the consumer needs to interleave its own logic (e.g., the client framework integration).
+
+---
+
+## Adapter Contract
+
+### Mandatory events and ordering
+
+Every adapter `chatStream()` implementation MUST emit events in this order:
+
+```
+RUN_STARTED                                     ← exactly once, first
+  (TEXT_MESSAGE_START                           ← before any text content
+    TEXT_MESSAGE_CONTENT*                       ← zero or more text deltas
+  TEXT_MESSAGE_END)?                            ← if text was started
+  (TOOL_CALL_START                              ← before any args for this tool
+    TOOL_CALL_ARGS*                             ← zero or more arg deltas
+  TOOL_CALL_END)?                               ← for each tool call
+RUN_FINISHED | RUN_ERROR                        ← exactly once, last
+```
+
+In EBNF-like notation:
+
+```
+Stream        ::= RUN_STARTED Content* Terminal
+Content       ::= TextBlock | ToolCall | ThinkingBlock
+TextBlock     ::= TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT* TEXT_MESSAGE_END
+ToolCall      ::= TOOL_CALL_START TOOL_CALL_ARGS* TOOL_CALL_END
+ThinkingBlock ::= STEP_STARTED? STEP_FINISHED+
+Terminal      ::= RUN_FINISHED | RUN_ERROR
+```
+
+### Event field requirements
+
+#### RUN_STARTED
+- `runId: string` -- Unique run identifier.
+
+#### TEXT_MESSAGE_START
+- `messageId: string` -- Unique message identifier.
+- `role: "assistant"` -- Always assistant for generated messages.
+
+#### TEXT_MESSAGE_CONTENT
+- `messageId: string` -- Must match the preceding TEXT_MESSAGE_START.
+- `delta: string` -- **Non-empty** incremental text token.
+- `content?: string` -- (Optional) Full accumulated text. For debugging only; processor uses `delta`.
+
+#### TEXT_MESSAGE_END
+- `messageId: string` -- Must match.
+
+#### TOOL_CALL_START
+- `toolCallId: string` -- **Globally unique** within this stream. The processor uses this as a Map key.
+- `toolName: string` -- Name of the tool being called. Must not be empty.
+- `index?: number` -- (Optional) For parallel tool calls. Defaults to `toolCalls.size` (order of arrival).
+
+#### TOOL_CALL_ARGS
+- `toolCallId: string` -- Must match a preceding TOOL_CALL_START.
+- `delta: string` -- Incremental JSON argument fragment. Concatenated by the processor.
+
+#### TOOL_CALL_END
+- `toolCallId: string` -- Must match.
+- `toolName: string` -- Must match.
+- `input?: unknown` -- (Optional) Parsed arguments. If provided, overrides the processor's accumulated string parse.
+- `result?: string` -- (Optional) **Adapters MUST NOT set this.** Reserved for TextEngine tool execution results.
+
+#### RUN_FINISHED
+- `runId: string` -- Must match RUN_STARTED.
+- `finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null`
+  - `"tool_calls"` -- The model wants tool execution. TextEngine will enter tool phase.
+  - `"stop"` -- Normal completion. TextEngine exits loop.
+  - Other values are informational.
+- `usage?: { promptTokens, completionTokens, totalTokens }` -- (Optional) Token usage.
+
+#### RUN_ERROR
+- `error: { message: string, code?: string }` -- Error details.
+
+#### STEP_FINISHED (thinking)
+- `stepId: string` -- Step identifier.
+- `delta: string` -- Incremental thinking content.
+- `content?: string` -- (Optional) Full accumulated thinking.
+
+### What adapters MUST NOT do
+
+1. **Do not emit `TOOL_CALL_ARGS` before `TOOL_CALL_START` for the same `toolCallId`.** The processor will silently drop the args (no entry in the Map).
+
+2. **Do not emit `TOOL_CALL_END` with a `result` field.** This field is reserved for the TextEngine. If an adapter sets it, the processor will immediately create a tool-result part, corrupting the agent loop flow.
+
+3. **Do not omit `RUN_FINISHED`.** Without it, the TextEngine cannot determine whether to execute tools. The StreamProcessor's `finalizeStream()` will force-complete tool calls as a safety net, but this is a fallback for network errors -- not normal operation.
+
+4. **Do not emit `TEXT_MESSAGE_CONTENT` without a preceding `TEXT_MESSAGE_START`.** The text will still be processed (the processor doesn't validate this), but `TEXT_MESSAGE_START` is needed to reset the segment accumulator for correct multi-segment text after tool calls.
+
+5. **Do not use empty strings for `toolCallId` or `toolName`.** The `ToolCallManager.getToolCalls()` filters out entries where `name.trim().length === 0`.
+
+6. **Do not reuse `toolCallId` values within a single stream.** The processor's Map deduplicates by ID -- a second `TOOL_CALL_START` with the same ID is silently ignored.
+
+### What adapters SHOULD do (recommended)
+
+1. Provide `TOOL_CALL_END.input` with parsed arguments. This gives the processor an authoritative parse rather than relying on partial-JSON parsing of accumulated deltas.
+
+2. Provide `TEXT_MESSAGE_CONTENT.content` with accumulated text. Useful for debugging, but the processor always uses `delta` for accumulation.
+
+3. Use deterministic `toolCallId` values from the provider (e.g., OpenAI's `call_*` IDs) rather than generating random ones, to enable debugging and replay.
+
+---
+
+## StreamProcessor Internal State
+
+### Stream-scoped state (reset per `process()` or `prepareAssistantMessage()`)
+
+| Field | Type | Purpose |
+|---|---|---|
+| `totalTextContent` | `string` | All text across all segments (for `ProcessorResult.content`) |
+| `currentSegmentText` | `string` | Text in the current segment (reset on `TEXT_MESSAGE_START`) |
+| `lastEmittedText` | `string` | Last text sent to `updateTextPart()` (prevents duplicate emissions) |
+| `thinkingContent` | `string` | Accumulated thinking text |
+| `toolCalls` | `Map<string, InternalToolCallState>` | Active tool calls keyed by `toolCallId` |
+| `toolCallOrder` | `string[]` | Order in which tool calls were first seen |
+| `finishReason` | `string \| null` | From `RUN_FINISHED` |
+| `isDone` | `boolean` | Set by `RUN_FINISHED` |
+
+### Conversation-scoped state (persists across streams)
+
+| Field | Type | Purpose |
+|---|---|---|
+| `messages` | `UIMessage[]` | The full conversation |
+| `currentAssistantMessageId` | `string \| null` | ID of the message being streamed (null before first content) |
+
+### The lazy message creation pattern
+
+The assistant UIMessage is **not** created when `prepareAssistantMessage()` is called. It's created lazily by `ensureAssistantMessage()` on the first content-bearing chunk. This prevents empty assistant messages from appearing in the UI during auto-continuation iterations that produce no content.
+
+Content-bearing chunks that trigger `ensureAssistantMessage()`:
+- `TEXT_MESSAGE_CONTENT`
+- `TOOL_CALL_START`
+- `STEP_FINISHED`
+- `RUN_ERROR`
+
+---
+
+## UIMessage Part Ordering Invariants
+
+The parts array within a single assistant UIMessage maintains a strict ordering that mirrors the stream:
+
+1. **ThinkingPart** (if present) -- Always managed as a single part, updated in place.
+2. **TextPart** (first segment) -- Created on first text content.
+3. **ToolCallPart(s)** -- Appended when tool calls start.
+4. **ToolResultPart(s)** -- Appended when tool results arrive.
+5. **TextPart** (second segment) -- Created when text resumes after tool calls.
+
+The critical logic is in `updateTextPart()`:
+- If the **last** part is a TextPart → **replace** its content (same segment continues).
+- If the **last** part is anything else → **push** a new TextPart (new segment after tools).
+
+This means tool-call and tool-result parts act as "segment separators" for text content.
+
+### Round-trip fidelity
+
+This ordering is preserved through `uiMessageToModelMessages()` which walks parts in order and flushes assistant segments at tool-result boundaries. See `messages.ts` for the conversion logic.
+
+---
+
+## Testing Strategy
+
+The StreamProcessor should be tested with recorded chunk sequences covering:
+
+### Single-shot scenarios (no agent loop)
+
+1. **Text only** -- `RUN_STARTED → TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END → RUN_FINISHED(stop)`
+2. **Tool call only (no text)** -- `RUN_STARTED → TOOL_CALL_START → TOOL_CALL_ARGS* → TOOL_CALL_END → RUN_FINISHED(tool_calls)`
+3. **Text then tool call** -- Text block followed by tool call block.
+4. **Parallel tool calls** -- Multiple interleaved TOOL_CALL_START/ARGS/END sequences.
+5. **Empty text deltas** -- Verify empty `delta` in `TOOL_CALL_ARGS` doesn't transition from `awaiting-input`.
+6. **Missing TOOL_CALL_END** -- Verify `RUN_FINISHED` safety net completes the tool call.
+7. **TOOL_CALL_END with input override** -- Verify `input` field overrides accumulated parse.
+8. **Thinking then text** -- STEP_FINISHED events followed by text events.
+
+### Tool result scenarios
+
+9. **TOOL_CALL_END with result** -- Verify both `output` on tool-call part and `tool-result` part are created.
+10. **Client tool via CUSTOM** -- Verify `onToolCall` fires, then `addToolResult()` creates correct parts.
+11. **Approval flow** -- Verify state transitions: `input-complete → approval-requested → approval-responded`.
+
+### Multi-segment text
+
+12. **Text → tool → text** -- Verify two separate TextParts with TEXT_MESSAGE_START reset.
+13. **Text → tool → result → text** -- Verify parts order: text, tool-call, tool-result, text.
+
+### Edge cases
+
+14. **No content (empty stream)** -- Only `RUN_STARTED` + `RUN_FINISHED`. No assistant message created.
+15. **RUN_ERROR** -- Verify assistant message is created and `onError` fires.
+16. **Duplicate TOOL_CALL_START** (same ID) -- Verify it's a no-op.
+17. **TOOL_CALL_ARGS for unknown ID** -- Verify silently dropped.
+
+Each test should assert:
+- The final `UIMessage.parts` array matches expected types, order, and content.
+- The `ProcessorResult` has correct `content`, `toolCalls`, and `finishReason`.
+- `onMessagesChange` was called the expected number of times.
+- Granular events (`onTextUpdate`, `onToolCallStateChange`) fired with correct arguments.
diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts
index 7402ba8be..4595671e4 100644
--- a/packages/typescript/ai/src/activities/chat/index.ts
+++ b/packages/typescript/ai/src/activities/chat/index.ts
@@ -555,15 +555,6 @@ class TextEngine<
       })
     }
 
-    // Don't overwrite a tool_calls finishReason with a stop finishReason
-    if (
-      this.finishedEvent?.finishReason === 'tool_calls' &&
-      chunk.finishReason === 'stop'
-    ) {
-      this.lastFinishReason = chunk.finishReason
-      return
-    }
-
     this.finishedEvent = chunk
     this.lastFinishReason = chunk.finishReason
   }
@@ -800,6 +791,18 @@ class TextEngine<
         } catch {
           output = message.content
         }
+        // Skip approval response messages (they have pendingExecution marker)
+        // These are NOT real client tool results — they are synthetic tool messages
+        // created by uiMessageToModelMessages for approved-but-not-yet-executed tools.
+        // Treating them as results would prevent the server from requesting actual
+        // client-side execution after approval (see GitHub issue #225).
+        if (
+          output &&
+          typeof output === 'object' &&
+          (output as any).pendingExecution === true
+        ) {
+          continue
+        }
         clientToolResults.set(message.toolCallId, output)
       }
     }
diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts
index 43e4a614e..c8dc9228a 100644
--- a/packages/typescript/ai/src/activities/chat/messages.ts
+++ b/packages/typescript/ai/src/activities/chat/messages.ts
@@ -1,27 +1,22 @@
 import type {
-  AudioPart,
   ContentPart,
-  DocumentPart,
-  ImagePart,
   MessagePart,
   ModelMessage,
   TextPart,
   ToolCallPart,
-  ToolResultPart,
   UIMessage,
-  VideoPart,
 } from '../../types'
 // ===========================
 // Message Converters
 // ===========================
 
 /**
- * Helper to check if a part is a multimodal content part (image, audio, video, document)
+ * Check if a MessagePart is a content part (text, image, audio, video, document)
+ * that maps directly to a ModelMessage ContentPart.
  */
-function isMultimodalPart(
-  part: MessagePart,
-): part is ImagePart | AudioPart | VideoPart | DocumentPart {
+function isContentPart(part: MessagePart): part is ContentPart {
   return (
+    part.type === 'text' ||
     part.type === 'image' ||
     part.type === 'audio' ||
     part.type === 'video' ||
@@ -30,19 +25,34 @@ function isMultimodalPart(
 }
 
 /**
- * Helper to extract text content from string or ContentPart array
- * For multimodal content, this extracts only the text parts
+ * Collapse an array of ContentParts into the most compact ModelMessage content:
+ * - Empty array → null
+ * - All text parts → joined string (or null if empty)
+ * - Mixed content → ContentPart array as-is
  */
-function getTextContent(content: string | null | Array<ContentPart>): string {
-  if (content === null) {
-    return ''
-  }
-  if (typeof content === 'string') {
-    return content
+function collapseContentParts(
+  parts: Array<ContentPart>,
+): string | null | Array<ContentPart> {
+  if (parts.length === 0) return null
+
+  const allText = parts.every((p) => p.type === 'text')
+  if (allText) {
+    const joined = parts.map((p) => p.content).join('')
+    return joined || null
   }
-  // Extract text from ContentPart array
+
+  return parts
+}
+
+/**
+ * Extract text content from ModelMessage content (string, null, or ContentPart array).
+ * Used when only the text portion is needed (e.g., tool result content).
+ */
+function getTextContent(content: string | null | Array<ContentPart>): string {
+  if (content === null) return ''
+  if (typeof content === 'string') return content
   return content
-    .filter((part) => part.type === 'text')
+    .filter((part): part is TextPart => part.type === 'text')
     .map((part) => part.content)
     .join('')
 }
@@ -69,157 +79,214 @@ export function convertMessagesToModelMessages(
 /**
  * Convert a UIMessage to ModelMessage(s)
  *
- * This conversion handles the parts-based structure:
- * - Text parts → content field (string or as part of ContentPart array)
- * - Multimodal parts (image, audio, video, document) → ContentPart array
- * - ToolCall parts → toolCalls array
- * - ToolResult parts → separate role="tool" messages
+ * Walks the parts array IN ORDER to preserve the interleaving of text,
+ * tool calls, and tool results. This is critical for multi-round tool
+ * flows where the model generates text, calls a tool, gets the result,
+ * then generates more text and calls another tool.
+ *
+ * The output preserves the sequential structure:
+ *   text1 → toolCall1 → toolResult1 → text2 → toolCall2 → toolResult2
+ * becomes:
+ *   assistant: {content: "text1", toolCalls: [toolCall1]}
+ *   tool: toolResult1
+ *   assistant: {content: "text2", toolCalls: [toolCall2]}
+ *   tool: toolResult2
  *
  * @param uiMessage - The UIMessage to convert
- * @returns An array of ModelMessages (may be multiple if tool results are present)
+ * @returns An array of ModelMessages preserving part ordering
  */
 export function uiMessageToModelMessages(
   uiMessage: UIMessage,
 ): Array<ModelMessage> {
-  const messageList: Array<ModelMessage> = []
-
   // Skip system messages - they're handled via systemPrompts, not ModelMessages
   if (uiMessage.role === 'system') {
-    return messageList
+    return []
   }
 
-  // Separate parts by type
-  // Note: thinking parts are UI-only and not included in ModelMessages
-  const textParts: Array<TextPart> = []
-  const multimodalParts: Array<
-    ImagePart | AudioPart | VideoPart | DocumentPart
-  > = []
-  const toolCallParts: Array<ToolCallPart> = []
-  const toolResultParts: Array<ToolResultPart> = []
+  // For non-assistant messages (user), use the simpler path since they
+  // don't have tool calls or tool results to interleave
+  if (uiMessage.role !== 'assistant') {
+    return [buildUserOrToolMessage(uiMessage)]
+  }
 
+  // For assistant messages, walk parts in order to preserve interleaving
+  return buildAssistantMessages(uiMessage)
+}
+
+/**
+ * Build a single ModelMessage for user messages (simple path).
+ * Preserves ordering of text and multimodal content parts.
+ */
+function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage {
+  const contentParts: Array<ContentPart> = []
   for (const part of uiMessage.parts) {
-    if (part.type === 'text') {
-      textParts.push(part)
-    } else if (isMultimodalPart(part)) {
-      multimodalParts.push(part)
-    } else if (part.type === 'tool-call') {
-      toolCallParts.push(part)
-    } else if (part.type === 'tool-result') {
-      toolResultParts.push(part)
+    if (isContentPart(part)) {
+      contentParts.push(part)
     }
-    // thinking parts are skipped - they're UI-only
   }
 
-  // Build the content field
-  // If we have multimodal parts, use ContentPart array format
-  // Otherwise, use simple string format for backward compatibility
-  let content: string | null | Array<ContentPart>
-  if (multimodalParts.length > 0) {
-    // Build ContentPart array preserving the order of text and multimodal parts
-    const contentParts: Array<ContentPart> = []
-    for (const part of uiMessage.parts) {
-      if (part.type === 'text') {
-        contentParts.push(part)
-      } else if (isMultimodalPart(part)) {
-        contentParts.push(part)
-      }
+  return {
+    role: uiMessage.role as 'user' | 'assistant' | 'tool',
+    content: collapseContentParts(contentParts),
+  }
+}
+
+// Accumulator for building an assistant segment (content + tool calls)
+interface AssistantSegment {
+  contentParts: Array<ContentPart>
+  toolCalls: Array<{
+    id: string
+    type: 'function'
+    function: { name: string; arguments: string }
+  }>
+}
+
+function createSegment(): AssistantSegment {
+  return { contentParts: [], toolCalls: [] }
+}
+
+function isToolCallIncluded(part: ToolCallPart): boolean {
+  return (
+    part.state === 'input-complete' ||
+    part.state === 'approval-responded' ||
+    part.output !== undefined
+  )
+}
+
+/**
+ * Build ModelMessages for an assistant UIMessage, preserving the
+ * sequential interleaving of text, tool calls, and tool results.
+ *
+ * Walks parts in order. Text and tool-call parts accumulate into the
+ * current "segment". When a tool-result part is encountered, the
+ * current segment is flushed as an assistant message, then the tool
+ * result is emitted as a tool message.
+ */
+function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
+  const messageList: Array<ModelMessage> = []
+  let current = createSegment()
+
+  // Track emitted tool result IDs to avoid duplicates.
+  // A tool call can have BOTH an explicit tool-result part AND an output
+  // field on the tool-call part. We only want one per tool call ID.
+  const emittedToolResultIds = new Set<string>()
+
+  function flushSegment(): void {
+    const content = collapseContentParts(current.contentParts)
+    const hasContent = content !== null
+    const hasToolCalls = current.toolCalls.length > 0
+
+    if (hasContent || hasToolCalls) {
+      messageList.push({
+        role: 'assistant',
+        content,
+        ...(hasToolCalls && { toolCalls: current.toolCalls }),
+      })
     }
-    content = contentParts
-  } else {
-    // Simple string content for text-only messages
-    content = textParts.map((p) => p.content).join('') || null
+    current = createSegment()
   }
 
-  const toolCalls =
-    toolCallParts.length > 0
-      ? toolCallParts
-          .filter(
-            (p) =>
-              p.state === 'input-complete' ||
-              p.state === 'approval-responded' ||
-              p.output !== undefined, // Include if has output (client tool result)
-          )
-          .map((p) => ({
-            id: p.id,
+  for (const part of uiMessage.parts) {
+    switch (part.type) {
+      case 'text':
+      case 'image':
+      case 'audio':
+      case 'video':
+      case 'document':
+        current.contentParts.push(part)
+        break
+
+      case 'tool-call':
+        if (isToolCallIncluded(part)) {
+          current.toolCalls.push({
+            id: part.id,
             type: 'function' as const,
             function: {
-              name: p.name,
-              arguments: p.arguments,
+              name: part.name,
+              arguments: part.arguments,
             },
-          }))
-      : undefined
+          })
+        }
+        break
 
-  // Create the main message
-  // For multimodal content, we always create a message even if content is an empty array
-  const hasContent = Array.isArray(content) ? true : content !== null
-  if (uiMessage.role !== 'assistant' || hasContent || !toolCalls) {
-    messageList.push({
-      role: uiMessage.role,
-      content,
-      ...(toolCalls && toolCalls.length > 0 && { toolCalls }),
-    })
-  } else if (toolCalls.length > 0) {
-    // Assistant message with only tool calls
-    messageList.push({
-      role: 'assistant',
-      content,
-      toolCalls,
-    })
-  }
+      case 'tool-result':
+        // Flush the current assistant segment before emitting the tool result
+        flushSegment()
 
-  // Add tool result messages for completed tool calls
-  // This includes:
-  // 1. Explicit tool-result parts (from server tools)
-  // 2. Client tool calls with output set
-  // 3. Approval-responded tool calls (approval result)
-  for (const toolResultPart of toolResultParts) {
-    if (
-      toolResultPart.state === 'complete' ||
-      toolResultPart.state === 'error'
-    ) {
-      messageList.push({
-        role: 'tool',
-        content: toolResultPart.content,
-        toolCallId: toolResultPart.toolCallId,
-      })
+        // Emit the tool result
+        if (
+          (part.state === 'complete' || part.state === 'error') &&
+          !emittedToolResultIds.has(part.toolCallId)
+        ) {
+          messageList.push({
+            role: 'tool',
+            content: part.content,
+            toolCallId: part.toolCallId,
+          })
+          emittedToolResultIds.add(part.toolCallId)
+        }
+        break
+
+      // thinking parts are skipped - they're UI-only
+      default:
+        break
     }
   }
 
-  // Add tool result messages for client tool results (tools with output)
-  // and approval responses (so iteration tracking works correctly)
-  for (const toolCallPart of toolCallParts) {
-    // Client tool with output - add as tool result
-    if (toolCallPart.output !== undefined && !toolCallPart.approval) {
+  // Flush any remaining accumulated content
+  flushSegment()
+
+  // Emit tool results from client tool-call parts with output or approval,
+  // but only if not already covered by an explicit tool-result part above.
+  // These are appended at the end since they don't have explicit tool-result
+  // parts in the parts array to trigger inline emission.
+  for (const part of uiMessage.parts) {
+    if (part.type !== 'tool-call') continue
+
+    // Client tool with output - add as tool result (if not already emitted)
+    if (
+      part.output !== undefined &&
+      !part.approval &&
+      !emittedToolResultIds.has(part.id)
+    ) {
       messageList.push({
         role: 'tool',
-        content: JSON.stringify(toolCallPart.output),
-        toolCallId: toolCallPart.id,
+        content: JSON.stringify(part.output),
+        toolCallId: part.id,
       })
+      emittedToolResultIds.add(part.id)
     }
 
     // Approval response - add as tool result for iteration tracking
-    // For APPROVED: includes pendingExecution marker so the tool still executes
-    // For DENIED: just marks the tool as complete (no execution needed)
     if (
-      toolCallPart.state === 'approval-responded' &&
-      toolCallPart.approval?.approved !== undefined
+      part.state === 'approval-responded' &&
+      part.approval?.approved !== undefined &&
+      !emittedToolResultIds.has(part.id)
     ) {
-      const approved = toolCallPart.approval.approved
+      const approved = part.approval.approved
       messageList.push({
         role: 'tool',
         content: JSON.stringify({
           approved,
-          // Mark approved tools as pending execution - they still need to run
           ...(approved && { pendingExecution: true }),
           message: approved
             ? 'User approved this action'
             : 'User denied this action',
         }),
-        toolCallId: toolCallPart.id,
+        toolCallId: part.id,
       })
+      emittedToolResultIds.add(part.id)
     }
   }
 
+  // If no messages were produced (e.g., empty parts), emit a minimal assistant message
+  if (messageList.length === 0) {
+    messageList.push({
+      role: 'assistant',
+      content: null,
+    })
+  }
+
   return messageList
 }
 
@@ -241,13 +308,29 @@ export function modelMessageToUIMessage(
 ): UIMessage {
   const parts: Array<MessagePart> = []
 
-  // Handle content (convert multimodal content to text for UI)
-  const textContent = getTextContent(modelMessage.content)
-  if (textContent) {
+  // Handle tool results (when role is "tool") - only produce tool-result part,
+  // not a text part (the content IS the tool result, not display text)
+  if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
     parts.push({
-      type: 'text',
-      content: textContent,
+      type: 'tool-result',
+      toolCallId: modelMessage.toolCallId,
+      content: getTextContent(modelMessage.content),
+      state: 'complete',
     })
+  } else if (Array.isArray(modelMessage.content)) {
+    // Multimodal content - preserve all content parts as MessageParts
+    for (const part of modelMessage.content) {
+      parts.push(part)
+    }
+  } else {
+    // String or null content
+    const textContent = getTextContent(modelMessage.content)
+    if (textContent) {
+      parts.push({
+        type: 'text',
+        content: textContent,
+      })
+    }
   }
 
   // Handle tool calls
@@ -263,16 +346,6 @@ export function modelMessageToUIMessage(
     }
   }
 
-  // Handle tool results (when role is "tool")
-  if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
-    parts.push({
-      type: 'tool-result',
-      toolCallId: modelMessage.toolCallId,
-      content: getTextContent(modelMessage.content),
-      state: 'complete',
-    })
-  }
-
   return {
     id: id || generateMessageId(),
     role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role,
diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts
index 4f40ca2d6..96d95865d 100644
--- a/packages/typescript/ai/src/activities/chat/stream/processor.ts
+++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts
@@ -12,6 +12,9 @@
  * - Thinking/reasoning content
  * - Recording/replay for testing
  * - Event-driven architecture for UI updates
+ *
+ * @see docs/chat-architecture.md — Canonical reference for AG-UI chunk ordering,
+ *   adapter contract, single-shot flows, and expected UIMessage output.
  */
 import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
 import { defaultJSONParser } from './json-parser'
@@ -101,18 +104,18 @@ export interface StreamProcessorOptions {
  * StreamProcessor - State machine for processing AI response streams
  *
  * Manages the full UIMessage[] conversation and emits events on changes.
+ * Trusts the adapter contract: adapters emit clean AG-UI events in the
+ * correct order.
  *
  * State tracking:
  * - Full message array
  * - Current assistant message being streamed
- * - Text content accumulation
+ * - Text content accumulation (reset on TEXT_MESSAGE_START)
  * - Multiple parallel tool calls
- * - Tool call completion detection
+ * - Tool call completion via TOOL_CALL_END events
  *
- * Tool call completion is detected when:
- * 1. A new tool call starts at a different index
- * 2. Text content arrives
- * 3. Stream ends
+ * @see docs/chat-architecture.md#streamprocessor-internal-state — State field reference
+ * @see docs/chat-architecture.md#adapter-contract — What this class expects from adapters
  */
 export class StreamProcessor {
   private chunkStrategy: ChunkStrategy
@@ -134,9 +137,8 @@ export class StreamProcessor {
   private toolCalls: Map<string, InternalToolCallState> = new Map()
   private toolCallOrder: Array<string> = []
   private finishReason: string | null = null
+  private hasError = false
   private isDone = false
-  // Track if we've had tool calls since the last text segment started
-  private hasToolCallsSinceTextStart = false
 
   // Recording
   private recording: ChunkRecording | null = null
@@ -213,12 +215,52 @@ export class StreamProcessor {
   }
 
   /**
-   * Start streaming a new assistant message
-   * Returns the message ID
+   * Prepare for a new assistant message stream.
+   * Does NOT create the message immediately -- the message is created lazily
+   * when the first content-bearing chunk arrives via ensureAssistantMessage().
+   * This prevents empty assistant messages from flickering in the UI when
+   * auto-continuation produces no content.
    */
-  startAssistantMessage(): string {
+  prepareAssistantMessage(): void {
     // Reset stream state for new message
     this.resetStreamState()
+    // Clear the current assistant message ID so ensureAssistantMessage()
+    // will create a fresh message on the first content chunk
+    this.currentAssistantMessageId = null
+  }
+
+  /**
+   * @deprecated Use prepareAssistantMessage() instead. This eagerly creates
+   * an assistant message which can cause empty message flicker.
+   */
+  startAssistantMessage(): string {
+    this.prepareAssistantMessage()
+    return this.ensureAssistantMessage()
+  }
+
+  /**
+   * Get the current assistant message ID (if one has been created).
+   * Returns null if prepareAssistantMessage() was called but no content
+   * has arrived yet.
+   */
+  getCurrentAssistantMessageId(): string | null {
+    return this.currentAssistantMessageId
+  }
+
+  /**
+   * Lazily create the assistant message if it hasn't been created yet.
+   * Called by content handlers on the first content-bearing chunk.
+   * Returns the message ID.
+   *
+   * Content-bearing chunks that trigger this:
+   * TEXT_MESSAGE_CONTENT, TOOL_CALL_START, STEP_FINISHED, RUN_ERROR.
+   *
+   * @see docs/chat-architecture.md#streamprocessor-internal-state — Lazy creation pattern
+   */
+  private ensureAssistantMessage(): string {
+    if (this.currentAssistantMessageId) {
+      return this.currentAssistantMessageId
+    }
 
     const assistantMessage: UIMessage = {
       id: generateMessageId(),
@@ -398,7 +440,13 @@ export class StreamProcessor {
   }
 
   /**
-   * Process a single chunk from the stream
+   * Process a single chunk from the stream.
+   *
+   * Central dispatch for all AG-UI events. Each event type maps to a specific
+   * handler. Events not listed in the switch are intentionally ignored
+   * (RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
+   *
+   * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
    */
   processChunk(chunk: StreamChunk): void {
     // Record chunk if enabled
@@ -412,6 +460,10 @@ export class StreamProcessor {
 
     switch (chunk.type) {
       // AG-UI Events
+      case 'TEXT_MESSAGE_START':
+        this.handleTextMessageStartEvent()
+        break
+
       case 'TEXT_MESSAGE_CONTENT':
         this.handleTextMessageContentEvent(chunk)
         break
@@ -445,67 +497,53 @@ export class StreamProcessor {
         break
 
       default:
-        // RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_STARTED,
+        // RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED,
         // STATE_SNAPSHOT, STATE_DELTA - no special handling needed
         break
     }
   }
 
   /**
-   * Handle TEXT_MESSAGE_CONTENT event
+   * Handle TEXT_MESSAGE_START event — marks the beginning of a new text segment.
+   * Resets segment accumulation so text after tool calls starts fresh.
+   *
+   * This is the key mechanism for multi-segment text (text before and after tool
+   * calls becoming separate TextParts). Without this reset, all text would merge
+   * into a single TextPart and tool-call interleaving would be lost.
+   *
+   * @see docs/chat-architecture.md#single-shot-text-response — Step-by-step text processing
+   * @see docs/chat-architecture.md#text-then-tool-interleaving-single-shot — Multi-segment text
+   */
+  private handleTextMessageStartEvent(): void {
+    // Emit any pending text from a previous segment before resetting
+    if (this.currentSegmentText !== this.lastEmittedText) {
+      this.emitTextUpdate()
+    }
+    this.currentSegmentText = ''
+    this.lastEmittedText = ''
+  }
+
+  /**
+   * Handle TEXT_MESSAGE_CONTENT event.
+   *
+   * Accumulates delta into both currentSegmentText (for UI emission) and
+   * totalTextContent (for ProcessorResult). Lazily creates the assistant
+   * UIMessage on first content. Uses updateTextPart() which replaces the
+   * last TextPart or creates a new one depending on part ordering.
+   *
+   * @see docs/chat-architecture.md#single-shot-text-response — Text accumulation step-by-step
+   * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
    */
   private handleTextMessageContentEvent(
     chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
   ): void {
-    // Content arriving means all current tool calls are complete
-    this.completeAllToolCalls()
-
-    const previousSegment = this.currentSegmentText
-
-    // Detect if this is a NEW text segment (after tool calls) vs continuation
-    const isNewSegment =
-      this.hasToolCallsSinceTextStart &&
-      previousSegment.length > 0 &&
-      this.isNewTextSegment(chunk, previousSegment)
-
-    if (isNewSegment) {
-      // Emit any accumulated text before starting new segment
-      if (previousSegment !== this.lastEmittedText) {
-        this.emitTextUpdate()
-      }
-      // Reset SEGMENT text accumulation for the new text segment after tool calls
-      this.currentSegmentText = ''
-      this.lastEmittedText = ''
-      this.hasToolCallsSinceTextStart = false
-    }
+    this.ensureAssistantMessage()
 
-    const currentText = this.currentSegmentText
-    let nextText = currentText
-
-    // Prefer delta over content - delta is the incremental change
-    // Check for both undefined and empty string to avoid "undefined" string concatenation
-    if (chunk.delta !== undefined && chunk.delta !== '') {
-      nextText = currentText + chunk.delta
-    } else if (chunk.content !== undefined && chunk.content !== '') {
-      // Fallback: use content if delta is not provided
-      if (chunk.content.startsWith(currentText)) {
-        nextText = chunk.content
-      } else if (currentText.startsWith(chunk.content)) {
-        nextText = currentText
-      } else {
-        nextText = currentText + chunk.content
-      }
-    }
-
-    // Calculate the delta for totalTextContent
-    const textDelta = nextText.slice(currentText.length)
-    this.currentSegmentText = nextText
-    this.totalTextContent += textDelta
+    this.currentSegmentText += chunk.delta
+    this.totalTextContent += chunk.delta
 
-    // Use delta for chunk strategy if available
-    const chunkPortion = chunk.delta || chunk.content || ''
     const shouldEmit = this.chunkStrategy.shouldEmit(
-      chunkPortion,
+      chunk.delta,
       this.currentSegmentText,
     )
     if (shouldEmit && this.currentSegmentText !== this.lastEmittedText) {
@@ -514,13 +552,22 @@ export class StreamProcessor {
   }
 
   /**
-   * Handle TOOL_CALL_START event
+   * Handle TOOL_CALL_START event.
+   *
+   * Creates a new InternalToolCallState entry in the toolCalls Map and appends
+   * a ToolCallPart to the UIMessage. Duplicate toolCallId is a no-op.
+   *
+   * CRITICAL: This MUST be received before any TOOL_CALL_ARGS for the same
+   * toolCallId. Args for unknown IDs are silently dropped.
+   *
+   * @see docs/chat-architecture.md#single-shot-tool-call-response — Tool call state transitions
+   * @see docs/chat-architecture.md#parallel-tool-calls-single-shot — Parallel tracking by ID
+   * @see docs/chat-architecture.md#adapter-contract — Ordering requirements
    */
   private handleToolCallStartEvent(
     chunk: Extract<StreamChunk, { type: 'TOOL_CALL_START' }>,
   ): void {
-    // Mark that we've seen tool calls since the last text segment
-    this.hasToolCallsSinceTextStart = true
+    this.ensureAssistantMessage()
 
     const toolCallId = chunk.toolCallId
     const existingToolCall = this.toolCalls.get(toolCallId)
@@ -567,7 +614,16 @@ export class StreamProcessor {
   }
 
   /**
-   * Handle TOOL_CALL_ARGS event
+   * Handle TOOL_CALL_ARGS event.
+   *
+   * Appends the delta to the tool call's accumulated arguments string.
+   * Transitions state from awaiting-input → input-streaming on first non-empty delta.
+   * Attempts partial JSON parse on each update for UI preview.
+   *
+   * If toolCallId is not found in the Map (no preceding TOOL_CALL_START),
+   * this event is silently dropped.
+   *
+   * @see docs/chat-architecture.md#single-shot-tool-call-response — Step-by-step tool call processing
    */
   private handleToolCallArgsEvent(
     chunk: Extract<StreamChunk, { type: 'TOOL_CALL_ARGS' }>,
@@ -617,15 +673,53 @@ export class StreamProcessor {
   }
 
   /**
-   * Handle TOOL_CALL_END event
+   * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
+   *
+   * This event has a DUAL ROLE:
+   * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
+   * - With `result`: Signals tool was executed and result is available (from TextEngine).
+   *   Creates both output on the tool-call part AND a tool-result part.
+   *
+   * If `input` is provided, it overrides the accumulated string parse as the
+   * canonical parsed arguments.
+   *
+   * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
+   * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
    */
   private handleToolCallEndEvent(
     chunk: Extract<StreamChunk, { type: 'TOOL_CALL_END' }>,
   ): void {
-    const state: ToolResultState = 'complete'
+    // Transition the tool call to input-complete (the authoritative completion signal)
+    const existingToolCall = this.toolCalls.get(chunk.toolCallId)
+    if (existingToolCall && existingToolCall.state !== 'input-complete') {
+      const index = this.toolCallOrder.indexOf(chunk.toolCallId)
+      this.completeToolCall(index, existingToolCall)
+      // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
+      // arguments (overrides the accumulated string parse from completeToolCall)
+      if (chunk.input !== undefined) {
+        existingToolCall.parsedArguments = chunk.input
+      }
+    }
 
-    // Update UIMessage if we have a current assistant message
+    // Update UIMessage if we have a current assistant message and a result
     if (this.currentAssistantMessageId && chunk.result) {
+      const state: ToolResultState = 'complete'
+
+      // Step 1: Update the tool-call part's output field (for UI consistency
+      // with client tools — see GitHub issue #176)
+      let output: unknown
+      try {
+        output = JSON.parse(chunk.result)
+      } catch {
+        output = chunk.result
+      }
+      this.messages = updateToolCallWithOutput(
+        this.messages,
+        chunk.toolCallId,
+        output,
+      )
+
+      // Step 2: Create/update the tool-result part (for LLM conversation history)
       this.messages = updateToolResultPart(
         this.messages,
         this.currentAssistantMessageId,
@@ -638,7 +732,14 @@ export class StreamProcessor {
   }
 
   /**
-   * Handle RUN_FINISHED event
+   * Handle RUN_FINISHED event.
+   *
+   * Records the finishReason and calls completeAllToolCalls() as a safety net
+   * to force-complete any tool calls that didn't receive an explicit TOOL_CALL_END.
+   * This handles cases like aborted streams or adapter bugs.
+   *
+   * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
+   * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
    */
   private handleRunFinishedEvent(
     chunk: Extract<StreamChunk, { type: 'RUN_FINISHED' }>,
@@ -654,33 +755,26 @@ export class StreamProcessor {
   private handleRunErrorEvent(
     chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
   ): void {
+    this.hasError = true
+    this.ensureAssistantMessage()
     // Emit error event
     this.events.onError?.(new Error(chunk.error.message || 'An error occurred'))
   }
 
   /**
-   * Handle STEP_FINISHED event (for thinking/reasoning content)
+   * Handle STEP_FINISHED event (for thinking/reasoning content).
+   *
+   * Accumulates delta into thinkingContent and updates a single ThinkingPart
+   * in the UIMessage (replaced in-place, not appended).
+   *
+   * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
    */
   private handleStepFinishedEvent(
     chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
   ): void {
-    const previous = this.thinkingContent
-    let nextThinking = previous
-
-    // Prefer delta over content
-    if (chunk.delta && chunk.delta !== '') {
-      nextThinking = previous + chunk.delta
-    } else if (chunk.content && chunk.content !== '') {
-      if (chunk.content.startsWith(previous)) {
-        nextThinking = chunk.content
-      } else if (previous.startsWith(chunk.content)) {
-        nextThinking = previous
-      } else {
-        nextThinking = previous + chunk.content
-      }
-    }
+    this.ensureAssistantMessage()
 
-    this.thinkingContent = nextThinking
+    this.thinkingContent += chunk.delta
 
     // Update UIMessage
     if (this.currentAssistantMessageId) {
@@ -700,9 +794,14 @@ export class StreamProcessor {
   }
 
   /**
-   * Handle CUSTOM event
-   * Handles special custom events like 'tool-input-available' for client-side tool execution
-   * and 'approval-requested' for tool approval flows
+   * Handle CUSTOM event.
+   *
+   * Handles special custom events emitted by the TextEngine (not adapters):
+   * - 'tool-input-available': Client tool needs execution. Fires onToolCall.
+   * - 'approval-requested': Tool needs user approval. Updates tool-call part
+   *   state and fires onApprovalRequest.
+   *
+   * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details
    */
   private handleCustomEvent(
     chunk: Extract<StreamChunk, { type: 'CUSTOM' }>,
@@ -754,29 +853,13 @@ export class StreamProcessor {
   }
 
   /**
-   * Detect if an incoming content chunk represents a NEW text segment
-   */
-  private isNewTextSegment(
-    chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
-    previous: string,
-  ): boolean {
-    // Check if content is present (delta is always defined but may be empty string)
-    if (chunk.content !== undefined) {
-      if (chunk.content.length < previous.length) {
-        return true
-      }
-      if (
-        !chunk.content.startsWith(previous) &&
-        !previous.startsWith(chunk.content)
-      ) {
-        return true
-      }
-    }
-    return false
-  }
-
-  /**
-   * Complete all tool calls
+   * Complete all tool calls — safety net for stream termination.
+   *
+   * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
+   * not yet in input-complete state. Handles cases where TOOL_CALL_END was
+   * missed (adapter bug, network error, aborted stream).
+   *
+   * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
    */
   private completeAllToolCalls(): void {
     this.toolCalls.forEach((toolCall, id) => {
@@ -824,7 +907,13 @@ export class StreamProcessor {
   }
 
   /**
-   * Emit pending text update
+   * Emit pending text update.
+   *
+   * Calls updateTextPart() which has critical append-vs-replace logic:
+   * - If last UIMessage part is TextPart → replaces its content (same segment).
+   * - If last part is anything else → pushes new TextPart (new segment after tools).
+   *
+   * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
    */
   private emitTextUpdate(): void {
     this.lastEmittedText = this.currentSegmentText
@@ -854,10 +943,16 @@ export class StreamProcessor {
   }
 
   /**
-   * Finalize the stream - complete all pending operations
+   * Finalize the stream — complete all pending operations.
+   *
+   * Called when the async iterable ends (stream closed). Acts as the final
+   * safety net: completes any remaining tool calls, flushes un-emitted text,
+   * and fires onStreamEnd.
+   *
+   * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
    */
   finalizeStream(): void {
-    // Complete any remaining tool calls
+    // Safety net: complete any remaining tool calls (e.g. on network errors / aborted streams)
     this.completeAllToolCalls()
 
     // Emit any pending text if not already emitted
@@ -865,7 +960,25 @@ export class StreamProcessor {
       this.emitTextUpdate()
     }
 
-    // Emit stream end event
+    // Remove the assistant message if it only contains whitespace text
+    // (no tool calls, no meaningful content). This handles models like Gemini
+    // that sometimes return just "\n" during auto-continuation.
+    // Preserve the message on errors so the UI can show error state.
+    if (this.currentAssistantMessageId && !this.hasError) {
+      const assistantMessage = this.messages.find(
+        (m) => m.id === this.currentAssistantMessageId,
+      )
+      if (assistantMessage && this.isWhitespaceOnlyMessage(assistantMessage)) {
+        this.messages = this.messages.filter(
+          (m) => m.id !== this.currentAssistantMessageId,
+        )
+        this.emitMessagesChange()
+        this.currentAssistantMessageId = null
+        return
+      }
+    }
+
+    // Emit stream end event (only if a message was actually created)
     if (this.currentAssistantMessageId) {
       const assistantMessage = this.messages.find(
         (m) => m.id === this.currentAssistantMessageId,
@@ -950,8 +1063,8 @@ export class StreamProcessor {
     this.toolCalls.clear()
     this.toolCallOrder = []
     this.finishReason = null
+    this.hasError = false
     this.isDone = false
-    this.hasToolCallsSinceTextStart = false
     this.chunkStrategy.reset?.()
   }
 
@@ -964,6 +1077,17 @@ export class StreamProcessor {
     this.currentAssistantMessageId = null
   }
 
+  /**
+   * Check if a message contains only whitespace text and no other meaningful parts
+   * (no tool calls, tool results, thinking, etc.)
+   */
+  private isWhitespaceOnlyMessage(message: UIMessage): boolean {
+    if (message.parts.length === 0) return false
+    return message.parts.every(
+      (part) => part.type === 'text' && part.content.trim() === '',
+    )
+  }
+
   /**
    * Replay a recording through the processor
    */
diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
index 7bb767a67..6ea512841 100644
--- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
+++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
@@ -295,10 +295,7 @@ export async function executeToolCalls(
 
     // Parse arguments, throwing error if invalid JSON
     let input: unknown = {}
-    let argsStr = toolCall.function.arguments.trim() || '{}'
-    // Normalize "null" to "{}" — can occur when the model streams a tool_use
-    // block with no input_json_delta events (Anthropic adapter edge case)
-    if (argsStr === 'null') argsStr = '{}'
+    const argsStr = toolCall.function.arguments.trim() || '{}'
     if (argsStr) {
       try {
         input = JSON.parse(argsStr)
diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts
index ce2f30fab..4d7ca6e52 100644
--- a/packages/typescript/ai/src/types.ts
+++ b/packages/typescript/ai/src/types.ts
@@ -789,9 +789,9 @@ export interface TextMessageContentEvent extends BaseAGUIEvent {
   type: 'TEXT_MESSAGE_CONTENT'
   /** Message identifier */
   messageId: string
-  /** The incremental content token (may be undefined if only content is provided) */
-  delta?: string
-  /** Full accumulated content so far */
+  /** The incremental content token */
+  delta: string
+  /** Full accumulated content so far (optional, for debugging) */
   content?: string
 }
 
@@ -864,8 +864,8 @@ export interface StepFinishedEvent extends BaseAGUIEvent {
   /** Step identifier */
   stepId: string
   /** Incremental thinking content */
-  delta?: string
-  /** Full accumulated thinking content */
+  delta: string
+  /** Full accumulated thinking content (optional, for debugging) */
   content?: string
 }
 
diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts
new file mode 100644
index 000000000..d47939c94
--- /dev/null
+++ b/packages/typescript/ai/tests/chat.test.ts
@@ -0,0 +1,1080 @@
+import { describe, expect, it, vi } from 'vitest'
+import { chat, createChatOptions } from '../src/activities/chat/index'
+import type { AnyTextAdapter } from '../src/activities/chat/adapter'
+import type { StreamChunk, Tool } from '../src/types'
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/** Create a typed StreamChunk with minimal boilerplate. */
+function chunk<T extends StreamChunk['type']>(
+  type: T,
+  fields: Omit<Extract<StreamChunk, { type: T }>, 'type' | 'timestamp'>,
+): StreamChunk {
+  return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk
+}
+
+/** Shorthand chunk factories for common AG-UI events. */
+const ev = {
+  runStarted: (runId = 'run-1') => chunk('RUN_STARTED', { runId }),
+  textStart: (messageId = 'msg-1') =>
+    chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }),
+  textContent: (delta: string, messageId = 'msg-1') =>
+    chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }),
+  textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }),
+  toolStart: (toolCallId: string, toolName: string, index?: number) =>
+    chunk('TOOL_CALL_START', {
+      toolCallId,
+      toolName,
+      ...(index !== undefined ? { index } : {}),
+    }),
+  toolArgs: (toolCallId: string, delta: string) =>
+    chunk('TOOL_CALL_ARGS', { toolCallId, delta }),
+  toolEnd: (
+    toolCallId: string,
+    toolName: string,
+    opts?: { input?: unknown; result?: string },
+  ) => chunk('TOOL_CALL_END', { toolCallId, toolName, ...opts }),
+  runFinished: (
+    finishReason:
+      | 'stop'
+      | 'length'
+      | 'content_filter'
+      | 'tool_calls'
+      | null = 'stop',
+    runId = 'run-1',
+  ) => chunk('RUN_FINISHED', { runId, finishReason }),
+  runError: (message: string, runId = 'run-1') =>
+    chunk('RUN_ERROR', { runId, error: { message } }),
+  stepFinished: (delta: string, stepId = 'step-1') =>
+    chunk('STEP_FINISHED', { stepId, delta }),
+}
+
+/**
+ * Create a mock adapter that satisfies AnyTextAdapter.
+ * `chatStreamFn` receives the options and returns an AsyncIterable of chunks.
+ * Multiple invocations can be tracked via the returned `calls` array.
+ */
+function createMockAdapter(options: {
+  chatStreamFn?: (opts: any) => AsyncIterable<StreamChunk>
+  /** Array of chunk sequences: chatStream returns iterations[0] on first call, iterations[1] on second, etc. */
+  iterations?: Array<Array<StreamChunk>>
+  structuredOutput?: (opts: any) => Promise<{ data: unknown; rawText: string }>
+}) {
+  const calls: Array<any> = []
+  let callIndex = 0
+
+  const adapter: AnyTextAdapter = {
+    kind: 'text' as const,
+    name: 'mock',
+    model: 'test-model' as const,
+    '~types': {
+      providerOptions: {} as Record<string, any>,
+      inputModalities: ['text'] as readonly ['text'],
+      messageMetadataByModality: {
+        text: undefined as unknown,
+        image: undefined as unknown,
+        audio: undefined as unknown,
+        video: undefined as unknown,
+        document: undefined as unknown,
+      },
+    },
+    chatStream: (opts: any) => {
+      calls.push(opts)
+
+      if (options.chatStreamFn) {
+        return options.chatStreamFn(opts)
+      }
+
+      if (options.iterations) {
+        const chunks = options.iterations[callIndex] || []
+        callIndex++
+        return (async function* () {
+          for (const c of chunks) yield c
+        })()
+      }
+
+      return (async function* () {})()
+    },
+    structuredOutput:
+      options.structuredOutput ?? (async () => ({ data: {}, rawText: '{}' })),
+  }
+
+  return { adapter, calls }
+}
+
+/** Collect all chunks from an async iterable. */
+async function collectChunks(
+  stream: AsyncIterable<StreamChunk>,
+): Promise<Array<StreamChunk>> {
+  const chunks: Array<StreamChunk> = []
+  for await (const c of stream) {
+    chunks.push(c)
+  }
+  return chunks
+}
+
+/** Simple server tool for testing. */
+function serverTool(name: string, executeFn: (args: any) => any): Tool {
+  return {
+    name,
+    description: `Test tool: ${name}`,
+    execute: executeFn,
+  }
+}
+
+/** Client tool (no execute function). */
+function clientTool(name: string, opts?: { needsApproval?: boolean }): Tool {
+  return {
+    name,
+    description: `Client tool: ${name}`,
+    needsApproval: opts?.needsApproval,
+  }
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('chat()', () => {
+  // ==========================================================================
+  // Streaming text (no tools)
+  // ==========================================================================
+  describe('streaming text (no tools)', () => {
+    it('should return an async iterable that yields all adapter chunks', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Hello'),
+            ev.textContent(' world!'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(chunks.length).toBe(6)
+      expect(chunks[0]!.type).toBe('RUN_STARTED')
+      expect(chunks[1]!.type).toBe('TEXT_MESSAGE_START')
+      expect(chunks[2]!.type).toBe('TEXT_MESSAGE_CONTENT')
+      expect(chunks[3]!.type).toBe('TEXT_MESSAGE_CONTENT')
+      expect(chunks[4]!.type).toBe('TEXT_MESSAGE_END')
+      expect(chunks[5]!.type).toBe('RUN_FINISHED')
+    })
+
+    it('should pass messages to the adapter', async () => {
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          [ev.runStarted(), ev.textContent('Hi'), ev.runFinished('stop')],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hello' }],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(calls).toHaveLength(1)
+      expect(calls[0].messages).toBeDefined()
+      expect(calls[0].messages[0].role).toBe('user')
+    })
+
+    it('should pass systemPrompts to the adapter', async () => {
+      const { adapter, calls } = createMockAdapter({
+        iterations: [[ev.runStarted(), ev.runFinished('stop')]],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hello' }],
+        systemPrompts: ['You are a helpful assistant'],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(calls[0].systemPrompts).toEqual(['You are a helpful assistant'])
+    })
+
+    it('should pass temperature, topP, maxTokens to the adapter', async () => {
+      const { adapter, calls } = createMockAdapter({
+        iterations: [[ev.runStarted(), ev.runFinished('stop')]],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hello' }],
+        temperature: 0.5,
+        topP: 0.9,
+        maxTokens: 100,
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(calls[0].temperature).toBe(0.5)
+      expect(calls[0].topP).toBe(0.9)
+      expect(calls[0].maxTokens).toBe(100)
+    })
+  })
+
+  // ==========================================================================
+  // Non-streaming text (stream: false)
+  // ==========================================================================
+  describe('non-streaming text (stream: false)', () => {
+    it('should return a Promise<string> with collected text content', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Hello'),
+            ev.textContent(' world!'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const result = await chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+        stream: false,
+      })
+
+      expect(result).toBe('Hello world!')
+    })
+
+    it('should still execute tools under the hood when stream: false', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ temp: 72 })
+
+      const { adapter } = createMockAdapter({
+        iterations: [
+          // First call: tool call
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'getWeather'),
+            ev.toolArgs('call_1', '{"city":"NYC"}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // Second call: final text
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('72F in NYC'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const result = await chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Weather in NYC?' }],
+        tools: [serverTool('getWeather', executeSpy)],
+        stream: false,
+      })
+
+      expect(executeSpy).toHaveBeenCalledTimes(1)
+      expect(result).toBe('72F in NYC')
+    })
+  })
+
+  // ==========================================================================
+  // Server tool execution
+  // ==========================================================================
+  describe('server tool execution', () => {
+    it('should execute server tools and yield TOOL_CALL_END with result', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ temp: 72 })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          // First adapter call: model requests tool
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Let me check.'),
+            ev.textEnd(),
+            ev.toolStart('call_1', 'getWeather'),
+            ev.toolArgs('call_1', '{"city":"NYC"}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // Second adapter call: model produces final text
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('72F in NYC.'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Weather?' }],
+        tools: [serverTool('getWeather', executeSpy)],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Tool was executed
+      expect(executeSpy).toHaveBeenCalledTimes(1)
+      expect(executeSpy).toHaveBeenCalledWith({ city: 'NYC' })
+
+      // A TOOL_CALL_END chunk with result should have been yielded
+      const toolEndChunks = chunks.filter(
+        (c) => c.type === 'TOOL_CALL_END' && 'result' in c && c.result,
+      )
+      expect(toolEndChunks.length).toBeGreaterThanOrEqual(1)
+
+      // Adapter was called twice (tool call iteration + final text)
+      expect(calls).toHaveLength(2)
+
+      // Second call should have tool result in messages
+      const secondCallMessages = calls[1].messages
+      const toolResultMsg = secondCallMessages.find(
+        (m: any) => m.role === 'tool',
+      )
+      expect(toolResultMsg).toBeDefined()
+    })
+
+    it('should handle tool execution errors gracefully', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'failTool'),
+            ev.toolArgs('call_1', '{}'),
+            ev.runFinished('tool_calls'),
+          ],
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Error happened.'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Do something' }],
+        tools: [
+          serverTool('failTool', () => {
+            throw new Error('Tool broke')
+          }),
+        ],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Should still complete and yield the error result
+      const toolEndChunks = chunks.filter(
+        (c) => c.type === 'TOOL_CALL_END' && 'result' in c,
+      )
+      expect(toolEndChunks.length).toBeGreaterThanOrEqual(1)
+      // Error should be in the result
+      const resultStr = (toolEndChunks[0] as any).result
+      expect(resultStr).toContain('error')
+    })
+  })
+
+  // ==========================================================================
+  // Parallel tool calls
+  // ==========================================================================
+  describe('parallel tool calls', () => {
+    it('should execute multiple tool calls and yield all results', async () => {
+      const weatherSpy = vi.fn().mockReturnValue({ temp: 72 })
+      const timeSpy = vi.fn().mockReturnValue({ time: '3pm' })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'getWeather', 0),
+            ev.toolStart('call_2', 'getTime', 1),
+            ev.toolArgs('call_1', '{"city":"NYC"}'),
+            ev.toolArgs('call_2', '{"tz":"EST"}'),
+            ev.runFinished('tool_calls'),
+          ],
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('72F, 3pm EST'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Weather and time?' }],
+        tools: [
+          serverTool('getWeather', weatherSpy),
+          serverTool('getTime', timeSpy),
+        ],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(weatherSpy).toHaveBeenCalledTimes(1)
+      expect(timeSpy).toHaveBeenCalledTimes(1)
+
+      // Second adapter call should have both tool results
+      const secondCallMessages = calls[1].messages
+      const toolResultMsgs = secondCallMessages.filter(
+        (m: any) => m.role === 'tool',
+      )
+      expect(toolResultMsgs).toHaveLength(2)
+    })
+  })
+
+  // ==========================================================================
+  // Client tools (no execute)
+  // ==========================================================================
+  describe('client tools (no execute)', () => {
+    it('should yield CUSTOM tool-input-available event for client tools', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'clientSearch'),
+            ev.toolArgs('call_1', '{"query":"test"}'),
+            ev.runFinished('tool_calls'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Search for test' }],
+        tools: [clientTool('clientSearch')],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Should yield a CUSTOM event for tool-input-available
+      const customChunks = chunks.filter(
+        (c) =>
+          c.type === 'CUSTOM' && (c as any).name === 'tool-input-available',
+      )
+      expect(customChunks).toHaveLength(1)
+
+      const data = (customChunks[0] as any).data
+      expect(data.toolCallId).toBe('call_1')
+      expect(data.toolName).toBe('clientSearch')
+      expect(data.input).toEqual({ query: 'test' })
+    })
+  })
+
+  // ==========================================================================
+  // Approval flow
+  // ==========================================================================
+  describe('approval flow', () => {
+    it('should yield CUSTOM approval-requested event for tools with needsApproval', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'dangerousTool'),
+            ev.toolArgs('call_1', '{"action":"delete"}'),
+            ev.runFinished('tool_calls'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Delete something' }],
+        tools: [serverTool('dangerousTool', () => ({ ok: true }))].map((t) => ({
+          ...t,
+          needsApproval: true,
+        })),
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      const approvalChunks = chunks.filter(
+        (c) => c.type === 'CUSTOM' && (c as any).name === 'approval-requested',
+      )
+      expect(approvalChunks).toHaveLength(1)
+
+      const data = (approvalChunks[0] as any).data
+      expect(data.toolCallId).toBe('call_1')
+      expect(data.toolName).toBe('dangerousTool')
+      expect(data.approval.needsApproval).toBe(true)
+    })
+
+    it('should yield CUSTOM approval-requested for client tools with needsApproval', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'clientDanger'),
+            ev.toolArgs('call_1', '{}'),
+            ev.runFinished('tool_calls'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Do something' }],
+        tools: [clientTool('clientDanger', { needsApproval: true })],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      const approvalChunks = chunks.filter(
+        (c) => c.type === 'CUSTOM' && (c as any).name === 'approval-requested',
+      )
+      expect(approvalChunks).toHaveLength(1)
+    })
+  })
+
+  // ==========================================================================
+  // Pending tool calls from messages
+  // ==========================================================================
+  describe('pending tool calls from messages', () => {
+    it('should detect and execute pending tool calls from initial messages', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ temp: 72 })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          // After pending tool is executed, the engine calls the adapter for the next response
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('72F in NYC'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [
+          { role: 'user', content: 'Weather?' },
+          {
+            role: 'assistant',
+            content: 'Let me check.',
+            toolCalls: [
+              {
+                id: 'call_1',
+                type: 'function' as const,
+                function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
+              },
+            ],
+          },
+          // No tool result message -> pending!
+        ],
+        tools: [serverTool('getWeather', executeSpy)],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Tool should have been executed as pending
+      expect(executeSpy).toHaveBeenCalledTimes(1)
+
+      // TOOL_CALL_END with result should be in the stream
+      const toolEndChunks = chunks.filter(
+        (c) => c.type === 'TOOL_CALL_END' && 'result' in c && c.result,
+      )
+      expect(toolEndChunks.length).toBeGreaterThanOrEqual(1)
+
+      // Adapter should have been called with the tool result in messages
+      expect(calls).toHaveLength(1)
+      const adapterMessages = calls[0].messages
+      const toolMsg = adapterMessages.find((m: any) => m.role === 'tool')
+      expect(toolMsg).toBeDefined()
+    })
+
+    it('should skip pending tool calls that already have results', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ temp: 72 })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Already answered.'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [
+          { role: 'user', content: 'Weather?' },
+          {
+            role: 'assistant',
+            content: 'Let me check.',
+            toolCalls: [
+              {
+                id: 'call_1',
+                type: 'function' as const,
+                function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
+              },
+            ],
+          },
+          // Tool result IS present -> not pending
+          { role: 'tool', content: '{"temp":72}', toolCallId: 'call_1' },
+        ],
+        tools: [serverTool('getWeather', executeSpy)],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Tool should NOT have been executed again
+      expect(executeSpy).not.toHaveBeenCalled()
+      expect(calls).toHaveLength(1)
+    })
+  })
+
+  // ==========================================================================
+  // Agent loop strategy
+  // ==========================================================================
+  describe('agent loop strategy', () => {
+    it('should stop after custom strategy says stop', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ temp: 72 })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'getWeather'),
+            ev.toolArgs('call_1', '{"city":"NYC"}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // This second iteration should NOT be reached
+          [
+            ev.runStarted(),
+            ev.textContent('Should not see this'),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Weather?' }],
+        tools: [serverTool('getWeather', executeSpy)],
+        // Strategy that stops immediately (no iterations)
+        agentLoopStrategy: () => false,
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Only first adapter call (tool call) should happen
+      // The tool is executed but the loop doesn't continue to a second model call
+      expect(calls).toHaveLength(1)
+    })
+
+    it('should respect maxIterations strategy', async () => {
+      const executeSpy = vi.fn().mockReturnValue({ data: 'result' })
+
+      let callCount = 0
+      const { adapter, calls } = createMockAdapter({
+        chatStreamFn: () => {
+          callCount++
+          // Always return tool calls to test max iteration limit
+          return (async function* () {
+            yield ev.runStarted()
+            yield ev.toolStart(`call_${callCount}`, 'repeater')
+            yield ev.toolArgs(`call_${callCount}`, '{}')
+            yield ev.runFinished('tool_calls')
+          })()
+        },
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Repeat' }],
+        tools: [serverTool('repeater', executeSpy)],
+        // maxIterations(2): allows iteration 0 and 1
+        agentLoopStrategy: (state) => state.iterationCount < 2,
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Should have called the adapter 2 times (iterations 0 and 1)
+      // Each iteration has processText + executeToolCalls phases
+      expect(calls.length).toBe(2)
+    })
+  })
+
+  // ==========================================================================
+  // Abort handling
+  // ==========================================================================
+  describe('abort handling', () => {
+    it('should stop streaming when abort is called', async () => {
+      const abortController = new AbortController()
+      let chunkCount = 0
+
+      const { adapter } = createMockAdapter({
+        chatStreamFn: () => {
+          return (async function* () {
+            yield ev.runStarted()
+            yield ev.textStart()
+            yield ev.textContent('Hello')
+            // Abort after first content chunk is consumed
+            yield ev.textContent(' world')
+            yield ev.textContent(' more')
+            yield ev.textEnd()
+            yield ev.runFinished('stop')
+          })()
+        },
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+        abortController,
+      })
+
+      const chunks: Array<StreamChunk> = []
+      for await (const c of stream as AsyncIterable<StreamChunk>) {
+        chunks.push(c)
+        chunkCount++
+        if (chunkCount === 3) {
+          // Abort after receiving RUN_STARTED, TEXT_MESSAGE_START, first TEXT_MESSAGE_CONTENT
+          abortController.abort()
+        }
+      }
+
+      // Should have stopped early - not all 7 chunks received
+      expect(chunks.length).toBeLessThan(7)
+    })
+  })
+
+  // ==========================================================================
+  // Error handling
+  // ==========================================================================
+  describe('error handling', () => {
+    it('should yield RUN_ERROR and stop the loop', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Starting...'),
+            ev.runError('API rate limited'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // RUN_ERROR should be in the chunks
+      const errorChunks = chunks.filter((c) => c.type === 'RUN_ERROR')
+      expect(errorChunks).toHaveLength(1)
+      expect((errorChunks[0] as any).error.message).toBe('API rate limited')
+    })
+
+    it('should not continue the agent loop after RUN_ERROR', async () => {
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          [ev.runStarted(), ev.runError('Fatal error')],
+          // This should never be called
+          [
+            ev.runStarted(),
+            ev.textContent('Should not happen'),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      // Only first adapter call should happen
+      expect(calls).toHaveLength(1)
+    })
+  })
+
+  // ==========================================================================
+  // Structured output
+  // ==========================================================================
+  describe('structured output', () => {
+    it('should run agentic loop then call adapter.structuredOutput', async () => {
+      const structuredOutputSpy = vi.fn().mockResolvedValue({
+        data: { name: 'Alice', age: 30 },
+        rawText: '{"name":"Alice","age":30}',
+      })
+
+      const { adapter } = createMockAdapter({
+        iterations: [
+          // Agentic loop runs first
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('Generating...'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+        structuredOutput: structuredOutputSpy,
+      })
+
+      // Use a plain JSON Schema (not Standard Schema) so no validation step
+      const result = await chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Generate a person' }],
+        outputSchema: {
+          type: 'object',
+          properties: {
+            name: { type: 'string' },
+            age: { type: 'number' },
+          },
+        } as any,
+      })
+
+      expect(structuredOutputSpy).toHaveBeenCalledTimes(1)
+      expect(result).toEqual({ name: 'Alice', age: 30 })
+    })
+
+    it('should pass final messages to structuredOutput after tool execution', async () => {
+      const structuredOutputSpy = vi.fn().mockResolvedValue({
+        data: { summary: 'Weather is 72F' },
+        rawText: '{"summary":"Weather is 72F"}',
+      })
+
+      const { adapter } = createMockAdapter({
+        iterations: [
+          // First: tool call
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'getWeather'),
+            ev.toolArgs('call_1', '{"city":"NYC"}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // Second: final text
+          [ev.runStarted(), ev.textContent('Done.'), ev.runFinished('stop')],
+        ],
+        structuredOutput: structuredOutputSpy,
+      })
+
+      await chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Summarize weather' }],
+        tools: [serverTool('getWeather', () => ({ temp: 72 }))],
+        outputSchema: {
+          type: 'object',
+          properties: { summary: { type: 'string' } },
+        } as any,
+      })
+
+      // structuredOutput should have been called with messages that include tool results
+      const structuredCall = structuredOutputSpy.mock.calls[0]![0]
+      const messages = structuredCall.chatOptions.messages
+      const toolMsg = messages.find((m: any) => m.role === 'tool')
+      expect(toolMsg).toBeDefined()
+    })
+  })
+
+  // ==========================================================================
+  // Thinking/step events
+  // ==========================================================================
+  describe('thinking/step events', () => {
+    it('should yield STEP_FINISHED chunks through', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [
+            ev.runStarted(),
+            ev.stepFinished('Let me think'),
+            ev.stepFinished(' about this...'),
+            ev.textStart(),
+            ev.textContent('Answer!'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Think about it' }],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      const stepChunks = chunks.filter((c) => c.type === 'STEP_FINISHED')
+      expect(stepChunks).toHaveLength(2)
+      expect((stepChunks[0] as any).delta).toBe('Let me think')
+      expect((stepChunks[1] as any).delta).toBe(' about this...')
+    })
+  })
+
+  // ==========================================================================
+  // createChatOptions helper
+  // ==========================================================================
+  describe('createChatOptions', () => {
+    it('should return the same options object (passthrough)', () => {
+      const { adapter } = createMockAdapter({})
+
+      const options = createChatOptions({
+        adapter,
+        messages: [{ role: 'user', content: 'Hello' }],
+        temperature: 0.7,
+      })
+
+      expect(options.adapter).toBe(adapter)
+      expect(options.temperature).toBe(0.7)
+      expect(options.messages).toEqual([{ role: 'user', content: 'Hello' }])
+    })
+  })
+
+  // ==========================================================================
+  // Multi-iteration agent loop
+  // ==========================================================================
+  describe('multi-iteration agent loop', () => {
+    it('should handle two sequential tool call iterations', async () => {
+      const tool1Spy = vi.fn().mockReturnValue({ result: 'data1' })
+      const tool2Spy = vi.fn().mockReturnValue({ result: 'data2' })
+
+      const { adapter, calls } = createMockAdapter({
+        iterations: [
+          // Iteration 1: first tool call
+          [
+            ev.runStarted(),
+            ev.toolStart('call_1', 'tool1'),
+            ev.toolArgs('call_1', '{}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // Iteration 2: second tool call
+          [
+            ev.runStarted(),
+            ev.toolStart('call_2', 'tool2'),
+            ev.toolArgs('call_2', '{}'),
+            ev.runFinished('tool_calls'),
+          ],
+          // Iteration 3: final text
+          [
+            ev.runStarted(),
+            ev.textStart(),
+            ev.textContent('All done.'),
+            ev.textEnd(),
+            ev.runFinished('stop'),
+          ],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Do two things' }],
+        tools: [serverTool('tool1', tool1Spy), serverTool('tool2', tool2Spy)],
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+
+      expect(tool1Spy).toHaveBeenCalledTimes(1)
+      expect(tool2Spy).toHaveBeenCalledTimes(1)
+      expect(calls).toHaveLength(3)
+    })
+  })
+
+  // ==========================================================================
+  // Edge cases
+  // ==========================================================================
+  describe('edge cases', () => {
+    it('should handle empty messages array', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [
+          [ev.runStarted(), ev.textContent('Hello'), ev.runFinished('stop')],
+        ],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+      expect(chunks.length).toBeGreaterThan(0)
+    })
+
+    it('should handle adapter yielding no chunks', async () => {
+      const { adapter } = createMockAdapter({
+        iterations: [[]],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+      })
+
+      const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
+      // Should complete without error even with empty stream
+      expect(chunks).toHaveLength(0)
+    })
+
+    it('should pass modelOptions through to adapter', async () => {
+      const { adapter, calls } = createMockAdapter({
+        iterations: [[ev.runStarted(), ev.runFinished('stop')]],
+      })
+
+      const stream = chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+        modelOptions: { customParam: 'value' } as any,
+      })
+
+      await collectChunks(stream as AsyncIterable<StreamChunk>)
+      expect(calls[0].modelOptions).toEqual({ customParam: 'value' })
+    })
+
+    it('should handle TEXT_MESSAGE_CONTENT with content field', async () => {
+      const { adapter } = createMockAdapter({
+        chatStreamFn: () => {
+          return (async function* () {
+            yield ev.runStarted()
+            yield ev.textStart()
+            // Include the optional content field
+            yield {
+              type: 'TEXT_MESSAGE_CONTENT',
+              messageId: 'msg-1',
+              delta: 'Hello',
+              content: 'Hello',
+              timestamp: Date.now(),
+            } as StreamChunk
+            yield ev.textEnd()
+            yield ev.runFinished('stop')
+          })()
+        },
+      })
+
+      const result = await chat({
+        adapter,
+        messages: [{ role: 'user', content: 'Hi' }],
+        stream: false,
+      })
+
+      expect(result).toBe('Hello')
+    })
+  })
+})
diff --git a/packages/typescript/ai/tests/message-converters.test.ts b/packages/typescript/ai/tests/message-converters.test.ts
index 60df58ec0..8bf0cd7e1 100644
--- a/packages/typescript/ai/tests/message-converters.test.ts
+++ b/packages/typescript/ai/tests/message-converters.test.ts
@@ -1,6 +1,9 @@
 import { describe, expect, it } from 'vitest'
 import {
+  convertMessagesToModelMessages,
   modelMessageToUIMessage,
+  modelMessagesToUIMessages,
+  normalizeToUIMessage,
   uiMessageToModelMessages,
 } from '../src/activities/chat/messages'
 import type { ContentPart, ModelMessage, UIMessage } from '../src/types'
@@ -314,6 +317,13 @@ describe('Message Converters', () => {
         id: 'msg-1',
         role: 'assistant',
         parts: [
+          {
+            type: 'tool-call',
+            id: 'tool-1',
+            name: 'getWeather',
+            arguments: '{"city": "NYC"}',
+            state: 'input-complete',
+          },
           {
             type: 'tool-result',
             toolCallId: 'tool-1',
@@ -325,12 +335,156 @@ describe('Message Converters', () => {
 
       const result = uiMessageToModelMessages(uiMessage)
 
-      // Should have assistant message + tool message
+      // Should have assistant message (with tool call) + tool result message
       expect(result.length).toBe(2)
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.toolCalls?.[0]?.id).toBe('tool-1')
       expect(result[1]?.role).toBe('tool')
       expect(result[1]?.toolCallId).toBe('tool-1')
       expect(result[1]?.content).toBe('{"temp": 72}')
     })
+
+    it('should preserve interleaving of text, tool calls, and tool results', () => {
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Let me check the weather.' },
+          {
+            type: 'tool-call',
+            id: 'tc-1',
+            name: 'getWeather',
+            arguments: '{"city": "NYC"}',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-1',
+            content: '{"temp": 72}',
+            state: 'complete',
+          },
+          { type: 'text', content: 'The temperature is 72F.' },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      // Should produce: assistant(text1 + toolCall) → tool(result) → assistant(text2)
+      expect(result.length).toBe(3)
+
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.content).toBe('Let me check the weather.')
+      expect(result[0]?.toolCalls).toHaveLength(1)
+      expect(result[0]?.toolCalls?.[0]?.id).toBe('tc-1')
+
+      expect(result[1]?.role).toBe('tool')
+      expect(result[1]?.toolCallId).toBe('tc-1')
+      expect(result[1]?.content).toBe('{"temp": 72}')
+
+      expect(result[2]?.role).toBe('assistant')
+      expect(result[2]?.content).toBe('The temperature is 72F.')
+      expect(result[2]?.toolCalls).toBeUndefined()
+    })
+
+    it('should handle multi-round tool flow (text1 -> tool1 -> result1 -> text2 -> tool2 -> result2)', () => {
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Let me check our inventory.' },
+          {
+            type: 'tool-call',
+            id: 'tc-get',
+            name: 'getGuitars',
+            arguments: '',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-get',
+            content: '[{"id":7,"name":"Travelin Man"}]',
+            state: 'complete',
+          },
+          {
+            type: 'text',
+            content: 'I found a great guitar! Let me recommend it.',
+          },
+          {
+            type: 'tool-call',
+            id: 'tc-rec',
+            name: 'recommendGuitar',
+            arguments: '{"id": 7}',
+            state: 'input-complete',
+            output: { id: 7 },
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-rec',
+            content: '{"id":7}',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      // Should produce:
+      // 1. assistant(text1 + getGuitars)
+      // 2. tool(getGuitars result)
+      // 3. assistant(text2 + recommendGuitar)
+      // 4. tool(recommendGuitar result) -- only once, no duplicate
+      expect(result.length).toBe(4)
+
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.content).toBe('Let me check our inventory.')
+      expect(result[0]?.toolCalls?.[0]?.function.name).toBe('getGuitars')
+
+      expect(result[1]?.role).toBe('tool')
+      expect(result[1]?.toolCallId).toBe('tc-get')
+
+      expect(result[2]?.role).toBe('assistant')
+      expect(result[2]?.content).toBe(
+        'I found a great guitar! Let me recommend it.',
+      )
+      expect(result[2]?.toolCalls?.[0]?.function.name).toBe('recommendGuitar')
+
+      expect(result[3]?.role).toBe('tool')
+      expect(result[3]?.toolCallId).toBe('tc-rec')
+
+      // No duplicate tool result for recommendGuitar (has both output and tool-result)
+      const toolMessages = result.filter((m) => m.role === 'tool')
+      expect(toolMessages).toHaveLength(2)
+    })
+
+    it('should handle tool-call-only segment (no text before tool call)', () => {
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          {
+            type: 'tool-call',
+            id: 'tc-1',
+            name: 'getGuitars',
+            arguments: '{}',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-1',
+            content: '[]',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      expect(result.length).toBe(2)
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.content).toBeNull()
+      expect(result[0]?.toolCalls).toHaveLength(1)
+      expect(result[1]?.role).toBe('tool')
+    })
   })
 
   describe('modelMessageToUIMessage', () => {
@@ -358,7 +512,7 @@ describe('Message Converters', () => {
       expect(result.id).toBe('custom-id')
     })
 
-    it('should convert multimodal content to text', () => {
+    it('should preserve multimodal content parts', () => {
       const modelMessage: ModelMessage = {
         role: 'user',
         content: [
@@ -372,8 +526,13 @@ describe('Message Converters', () => {
 
       const result = modelMessageToUIMessage(modelMessage)
 
-      // Currently, modelMessageToUIMessage only extracts text content
-      expect(result.parts).toEqual([{ type: 'text', content: 'What is this?' }])
+      expect(result.parts).toEqual([
+        { type: 'text', content: 'What is this?' },
+        {
+          type: 'image',
+          source: { type: 'url', value: 'https://example.com/img.jpg' },
+        },
+      ])
     })
 
     it('should handle tool message', () => {
@@ -393,5 +552,963 @@ describe('Message Converters', () => {
         state: 'complete',
       })
     })
+
+    it('should convert assistant message with toolCalls and text', () => {
+      const modelMessage: ModelMessage = {
+        role: 'assistant',
+        content: 'Let me check the weather.',
+        toolCalls: [
+          {
+            id: 'tc-1',
+            type: 'function',
+            function: {
+              name: 'getWeather',
+              arguments: '{"city": "NYC"}',
+            },
+          },
+        ],
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.role).toBe('assistant')
+      expect(result.parts).toEqual([
+        { type: 'text', content: 'Let me check the weather.' },
+        {
+          type: 'tool-call',
+          id: 'tc-1',
+          name: 'getWeather',
+          arguments: '{"city": "NYC"}',
+          state: 'input-complete',
+        },
+      ])
+    })
+
+    it('should convert assistant message with toolCalls and null content', () => {
+      const modelMessage: ModelMessage = {
+        role: 'assistant',
+        content: null,
+        toolCalls: [
+          {
+            id: 'tc-1',
+            type: 'function',
+            function: {
+              name: 'getWeather',
+              arguments: '{"city": "NYC"}',
+            },
+          },
+        ],
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.role).toBe('assistant')
+      // Should have only tool-call part, no text part
+      expect(result.parts).toEqual([
+        {
+          type: 'tool-call',
+          id: 'tc-1',
+          name: 'getWeather',
+          arguments: '{"city": "NYC"}',
+          state: 'input-complete',
+        },
+      ])
+    })
+
+    it('should preserve multimodal content parts (image, audio, video, document)', () => {
+      const modelMessage: ModelMessage = {
+        role: 'user',
+        content: [
+          { type: 'text', content: 'What is this?' },
+          {
+            type: 'image',
+            source: { type: 'url', value: 'https://example.com/img.jpg' },
+          },
+          {
+            type: 'audio',
+            source: {
+              type: 'data',
+              value: 'base64audio',
+              mimeType: 'audio/mp3',
+            },
+          },
+          {
+            type: 'video',
+            source: { type: 'url', value: 'https://example.com/video.mp4' },
+          },
+          {
+            type: 'document',
+            source: {
+              type: 'data',
+              value: 'base64pdf',
+              mimeType: 'application/pdf',
+            },
+          },
+        ],
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.parts.length).toBe(5)
+      expect(result.parts[0]).toEqual({
+        type: 'text',
+        content: 'What is this?',
+      })
+      expect(result.parts[1]).toEqual({
+        type: 'image',
+        source: { type: 'url', value: 'https://example.com/img.jpg' },
+      })
+      expect(result.parts[2]).toEqual({
+        type: 'audio',
+        source: {
+          type: 'data',
+          value: 'base64audio',
+          mimeType: 'audio/mp3',
+        },
+      })
+      expect(result.parts[3]).toEqual({
+        type: 'video',
+        source: { type: 'url', value: 'https://example.com/video.mp4' },
+      })
+      expect(result.parts[4]).toEqual({
+        type: 'document',
+        source: {
+          type: 'data',
+          value: 'base64pdf',
+          mimeType: 'application/pdf',
+        },
+      })
+    })
+
+    it('should handle null content', () => {
+      const modelMessage: ModelMessage = {
+        role: 'assistant',
+        content: null,
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.role).toBe('assistant')
+      expect(result.parts).toEqual([])
+    })
+
+    it('should handle empty string content', () => {
+      const modelMessage: ModelMessage = {
+        role: 'assistant',
+        content: '',
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.role).toBe('assistant')
+      // Empty string has no text content, so no text part
+      expect(result.parts).toEqual([])
+    })
+
+    it('should not produce redundant text part for tool messages', () => {
+      const modelMessage: ModelMessage = {
+        role: 'tool',
+        content: '{"temp": 72}',
+        toolCallId: 'tool-1',
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      // Should have only the tool-result part, NOT a text part + tool-result
+      const textParts = result.parts.filter((p) => p.type === 'text')
+      const toolResultParts = result.parts.filter(
+        (p) => p.type === 'tool-result',
+      )
+      expect(textParts).toHaveLength(0)
+      expect(toolResultParts).toHaveLength(1)
+      expect(toolResultParts[0]).toEqual({
+        type: 'tool-result',
+        toolCallId: 'tool-1',
+        content: '{"temp": 72}',
+        state: 'complete',
+      })
+    })
+
+    it('should preserve multimodal content with metadata', () => {
+      const modelMessage: ModelMessage = {
+        role: 'user',
+        content: [
+          { type: 'text', content: 'Analyze' },
+          {
+            type: 'image',
+            source: { type: 'url', value: 'https://example.com/cat.jpg' },
+            metadata: { detail: 'high' },
+          },
+        ],
+      }
+
+      const result = modelMessageToUIMessage(modelMessage)
+
+      expect(result.parts.length).toBe(2)
+      expect(result.parts[1]).toEqual({
+        type: 'image',
+        source: { type: 'url', value: 'https://example.com/cat.jpg' },
+        metadata: { detail: 'high' },
+      })
+    })
+  })
+
+  describe('uiMessageToModelMessages - duplicate tool result prevention', () => {
+    it('should not create duplicate tool results when tool-call has output AND tool-result exists', () => {
+      // This scenario happens when a client tool executes: the UIMessage has both
+      // a tool-call part with output AND a tool-result part for the same toolCallId
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          {
+            type: 'text',
+            content: 'Let me recommend a guitar.',
+          },
+          {
+            type: 'tool-call',
+            id: 'tc-1',
+            name: 'recommendGuitar',
+            arguments: '{"id": 7}',
+            state: 'input-complete',
+            output: { id: 7 },
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-1',
+            content: '{"id":7}',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      // Should have: 1 assistant message + 1 tool result (NOT 2)
+      const toolMessages = result.filter((m) => m.role === 'tool')
+      expect(toolMessages).toHaveLength(1)
+      expect(toolMessages[0]?.toolCallId).toBe('tc-1')
+    })
+
+    it('should handle multi-round tool calls without duplicating results', () => {
+      // This scenario simulates the full multi-round message:
+      // text1 + getGuitars tool call + getGuitars result + text2 + recommendGuitar tool call + recommendGuitar result
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Let me check our inventory.' },
+          {
+            type: 'tool-call',
+            id: 'tc-get',
+            name: 'getGuitars',
+            arguments: '',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-get',
+            content: '[{"id":7,"name":"Travelin Man Guitar"}]',
+            state: 'complete',
+          },
+          { type: 'text', content: 'I found a great guitar!' },
+          {
+            type: 'tool-call',
+            id: 'tc-rec',
+            name: 'recommendGuitar',
+            arguments: '{"id": 7}',
+            state: 'input-complete',
+            output: { id: 7 },
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-rec',
+            content: '{"id":7}',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      // Should have exactly 2 tool result messages (one per tool call, no duplicates)
+      const toolMessages = result.filter((m) => m.role === 'tool')
+      expect(toolMessages).toHaveLength(2)
+      expect(toolMessages[0]?.toolCallId).toBe('tc-get')
+      expect(toolMessages[1]?.toolCallId).toBe('tc-rec')
+    })
+  })
+
+  describe('modelMessagesToUIMessages', () => {
+    it('should convert simple user + assistant conversation', () => {
+      const modelMessages: Array<ModelMessage> = [
+        { role: 'user', content: 'Hello' },
+        { role: 'assistant', content: 'Hi there!' },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      expect(result.length).toBe(2)
+      expect(result[0]?.role).toBe('user')
+      expect(result[0]?.parts).toEqual([{ type: 'text', content: 'Hello' }])
+      expect(result[1]?.role).toBe('assistant')
+      expect(result[1]?.parts).toEqual([{ type: 'text', content: 'Hi there!' }])
+    })
+
+    it('should merge tool result into preceding assistant message', () => {
+      const modelMessages: Array<ModelMessage> = [
+        {
+          role: 'assistant',
+          content: 'Let me check.',
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          content: '{"temp": 72}',
+          toolCallId: 'tc-1',
+        },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      // Tool result should be merged into the assistant message
+      expect(result.length).toBe(1)
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.parts).toEqual([
+        { type: 'text', content: 'Let me check.' },
+        {
+          type: 'tool-call',
+          id: 'tc-1',
+          name: 'getWeather',
+          arguments: '{"city":"NYC"}',
+          state: 'input-complete',
+        },
+        {
+          type: 'tool-result',
+          toolCallId: 'tc-1',
+          content: '{"temp": 72}',
+          state: 'complete',
+        },
+      ])
+    })
+
+    it('should handle multi-round tool flow with proper merging', () => {
+      const modelMessages: Array<ModelMessage> = [
+        {
+          role: 'assistant',
+          content: 'Checking inventory.',
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getGuitars', arguments: '' },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          content: '[{"id":7}]',
+          toolCallId: 'tc-1',
+        },
+        {
+          role: 'assistant',
+          content: 'Found one! Recommending.',
+          toolCalls: [
+            {
+              id: 'tc-2',
+              type: 'function',
+              function: { name: 'recommend', arguments: '{"id":7}' },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          content: '{"recommended":true}',
+          toolCallId: 'tc-2',
+        },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      // Each assistant message should have its tool result merged in
+      expect(result.length).toBe(2)
+
+      expect(result[0]?.parts).toEqual([
+        { type: 'text', content: 'Checking inventory.' },
+        {
+          type: 'tool-call',
+          id: 'tc-1',
+          name: 'getGuitars',
+          arguments: '',
+          state: 'input-complete',
+        },
+        {
+          type: 'tool-result',
+          toolCallId: 'tc-1',
+          content: '[{"id":7}]',
+          state: 'complete',
+        },
+      ])
+
+      expect(result[1]?.parts).toEqual([
+        { type: 'text', content: 'Found one! Recommending.' },
+        {
+          type: 'tool-call',
+          id: 'tc-2',
+          name: 'recommend',
+          arguments: '{"id":7}',
+          state: 'input-complete',
+        },
+        {
+          type: 'tool-result',
+          toolCallId: 'tc-2',
+          content: '{"recommended":true}',
+          state: 'complete',
+        },
+      ])
+    })
+
+    it('should create standalone message for orphan tool result', () => {
+      const modelMessages: Array<ModelMessage> = [
+        {
+          role: 'tool',
+          content: '{"result": "orphan"}',
+          toolCallId: 'tc-1',
+        },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      expect(result.length).toBe(1)
+      expect(result[0]?.role).toBe('assistant')
+      expect(result[0]?.parts).toContainEqual({
+        type: 'tool-result',
+        toolCallId: 'tc-1',
+        content: '{"result": "orphan"}',
+        state: 'complete',
+      })
+    })
+
+    it('should not merge tool result across user messages', () => {
+      const modelMessages: Array<ModelMessage> = [
+        { role: 'user', content: 'Hi' },
+        { role: 'assistant', content: 'Hello!' },
+        { role: 'user', content: 'Another question' },
+        {
+          role: 'tool',
+          content: '{"result": "orphan"}',
+          toolCallId: 'tc-1',
+        },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      // Tool result should NOT merge into the assistant message (user message in between)
+      expect(result.length).toBe(4)
+      expect(result[3]?.role).toBe('assistant')
+      expect(result[3]?.parts).toContainEqual({
+        type: 'tool-result',
+        toolCallId: 'tc-1',
+        content: '{"result": "orphan"}',
+        state: 'complete',
+      })
+    })
+
+    it('should handle complex interleaved conversation', () => {
+      const modelMessages: Array<ModelMessage> = [
+        { role: 'user', content: 'Check the weather' },
+        {
+          role: 'assistant',
+          content: null,
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
+            },
+          ],
+        },
+        { role: 'tool', content: '{"temp":72}', toolCallId: 'tc-1' },
+        { role: 'assistant', content: 'The temperature is 72F.' },
+      ]
+
+      const result = modelMessagesToUIMessages(modelMessages)
+
+      expect(result.length).toBe(3)
+      expect(result[0]?.role).toBe('user')
+
+      // Assistant with tool call + merged tool result
+      expect(result[1]?.role).toBe('assistant')
+      const assistantParts = result[1]?.parts || []
+      expect(assistantParts).toContainEqual({
+        type: 'tool-call',
+        id: 'tc-1',
+        name: 'getWeather',
+        arguments: '{"city":"NYC"}',
+        state: 'input-complete',
+      })
+      expect(assistantParts).toContainEqual({
+        type: 'tool-result',
+        toolCallId: 'tc-1',
+        content: '{"temp":72}',
+        state: 'complete',
+      })
+
+      // Final assistant text
+      expect(result[2]?.role).toBe('assistant')
+      expect(result[2]?.parts).toEqual([
+        { type: 'text', content: 'The temperature is 72F.' },
+      ])
+    })
+  })
+
+  describe('convertMessagesToModelMessages', () => {
+    it('should pass through ModelMessages unchanged', () => {
+      const messages: Array<ModelMessage> = [
+        { role: 'user', content: 'Hello' },
+        { role: 'assistant', content: 'Hi' },
+      ]
+
+      const result = convertMessagesToModelMessages(messages)
+
+      expect(result).toEqual(messages)
+    })
+
+    it('should convert UIMessages to ModelMessages', () => {
+      const messages: Array<UIMessage> = [
+        {
+          id: 'msg-1',
+          role: 'user',
+          parts: [{ type: 'text', content: 'Hello' }],
+        },
+      ]
+
+      const result = convertMessagesToModelMessages(messages)
+
+      expect(result).toEqual([{ role: 'user', content: 'Hello' }])
+    })
+
+    it('should handle mixed UIMessage and ModelMessage array', () => {
+      const messages: Array<UIMessage | ModelMessage> = [
+        {
+          id: 'msg-1',
+          role: 'user',
+          parts: [{ type: 'text', content: 'Hello' }],
+        },
+        { role: 'assistant', content: 'Hi there!' },
+      ]
+
+      const result = convertMessagesToModelMessages(messages)
+
+      expect(result).toEqual([
+        { role: 'user', content: 'Hello' },
+        { role: 'assistant', content: 'Hi there!' },
+      ])
+    })
+
+    it('should handle empty array', () => {
+      const result = convertMessagesToModelMessages([])
+      expect(result).toEqual([])
+    })
+  })
+
+  describe('normalizeToUIMessage', () => {
+    it('should pass through UIMessage with existing id and createdAt', () => {
+      const date = new Date('2025-01-01')
+      const message: UIMessage = {
+        id: 'existing-id',
+        role: 'user',
+        parts: [{ type: 'text', content: 'Hello' }],
+        createdAt: date,
+      }
+
+      const result = normalizeToUIMessage(message, () => 'generated-id')
+
+      expect(result.id).toBe('existing-id')
+      expect(result.createdAt).toBe(date)
+      expect(result.parts).toEqual([{ type: 'text', content: 'Hello' }])
+    })
+
+    it('should generate id for UIMessage without id', () => {
+      const message = {
+        id: '',
+        role: 'user' as const,
+        parts: [{ type: 'text' as const, content: 'Hello' }],
+      }
+
+      const result = normalizeToUIMessage(message, () => 'generated-id')
+
+      expect(result.id).toBe('generated-id')
+      expect(result.createdAt).toBeTruthy()
+    })
+
+    it('should convert ModelMessage to UIMessage', () => {
+      const message: ModelMessage = {
+        role: 'user',
+        content: 'Hello',
+      }
+
+      const result = normalizeToUIMessage(message, () => 'generated-id')
+
+      expect(result.id).toBe('generated-id')
+      expect(result.role).toBe('user')
+      expect(result.parts).toEqual([{ type: 'text', content: 'Hello' }])
+      expect(result.createdAt).toBeTruthy()
+    })
+  })
+
+  describe('Round-trip symmetry: UI -> Model -> UI', () => {
+    it('should round-trip simple text user message', () => {
+      const original: UIMessage = {
+        id: 'msg-1',
+        role: 'user',
+        parts: [{ type: 'text', content: 'Hello world' }],
+      }
+
+      const modelMessages = uiMessageToModelMessages(original)
+      const uiMessages = modelMessagesToUIMessages(modelMessages)
+
+      expect(uiMessages.length).toBe(1)
+      expect(uiMessages[0]?.role).toBe(original.role)
+      expect(uiMessages[0]?.parts).toEqual(original.parts)
+    })
+
+    it('should round-trip assistant with tool-call + tool-result', () => {
+      const original: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Let me check.' },
+          {
+            type: 'tool-call',
+            id: 'tc-1',
+            name: 'getWeather',
+            arguments: '{"city":"NYC"}',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-1',
+            content: '{"temp": 72}',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const modelMessages = uiMessageToModelMessages(original)
+      const uiMessages = modelMessagesToUIMessages(modelMessages)
+
+      // Should produce a single assistant UIMessage with all parts merged back
+      expect(uiMessages.length).toBe(1)
+      expect(uiMessages[0]?.role).toBe('assistant')
+      expect(uiMessages[0]?.parts).toEqual(original.parts)
+    })
+
+    it('should round-trip multimodal user message with image', () => {
+      const original: UIMessage = {
+        id: 'msg-1',
+        role: 'user',
+        parts: [
+          { type: 'text', content: 'What is this?' },
+          {
+            type: 'image',
+            source: { type: 'url', value: 'https://example.com/img.jpg' },
+          },
+        ],
+      }
+
+      const modelMessages = uiMessageToModelMessages(original)
+      const uiMessages = modelMessagesToUIMessages(modelMessages)
+
+      expect(uiMessages.length).toBe(1)
+      expect(uiMessages[0]?.role).toBe('user')
+      expect(uiMessages[0]?.parts).toEqual(original.parts)
+    })
+
+    it('should round-trip multi-round tool flow', () => {
+      const original: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Checking inventory.' },
+          {
+            type: 'tool-call',
+            id: 'tc-1',
+            name: 'getGuitars',
+            arguments: '',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-1',
+            content: '[{"id":7}]',
+            state: 'complete',
+          },
+          { type: 'text', content: 'Found one!' },
+          {
+            type: 'tool-call',
+            id: 'tc-2',
+            name: 'recommend',
+            arguments: '{"id":7}',
+            state: 'input-complete',
+          },
+          {
+            type: 'tool-result',
+            toolCallId: 'tc-2',
+            content: '{"recommended":true}',
+            state: 'complete',
+          },
+        ],
+      }
+
+      const modelMessages = uiMessageToModelMessages(original)
+      const uiMessages = modelMessagesToUIMessages(modelMessages)
+
+      // Multi-round should produce multiple UIMessages (one per segment)
+      // but when recombined, the structure should match segments
+      expect(uiMessages.length).toBe(2)
+
+      // First segment: text + tool-call + tool-result
+      expect(uiMessages[0]?.parts).toEqual([
+        { type: 'text', content: 'Checking inventory.' },
+        {
+          type: 'tool-call',
+          id: 'tc-1',
+          name: 'getGuitars',
+          arguments: '',
+          state: 'input-complete',
+        },
+        {
+          type: 'tool-result',
+          toolCallId: 'tc-1',
+          content: '[{"id":7}]',
+          state: 'complete',
+        },
+      ])
+
+      // Second segment: text + tool-call + tool-result
+      expect(uiMessages[1]?.parts).toEqual([
+        { type: 'text', content: 'Found one!' },
+        {
+          type: 'tool-call',
+          id: 'tc-2',
+          name: 'recommend',
+          arguments: '{"id":7}',
+          state: 'input-complete',
+        },
+        {
+          type: 'tool-result',
+          toolCallId: 'tc-2',
+          content: '{"recommended":true}',
+          state: 'complete',
+        },
+      ])
+    })
+  })
+
+  describe('Round-trip symmetry: Model -> UI -> Model', () => {
+    it('should round-trip simple text messages', () => {
+      const original: Array<ModelMessage> = [
+        { role: 'user', content: 'Hello' },
+        { role: 'assistant', content: 'Hi there!' },
+      ]
+
+      const uiMessages = modelMessagesToUIMessages(original)
+      const modelMessages = convertMessagesToModelMessages(uiMessages)
+
+      expect(modelMessages).toEqual(original)
+    })
+
+    it('should round-trip assistant with toolCalls + tool result', () => {
+      const original: Array<ModelMessage> = [
+        {
+          role: 'assistant',
+          content: 'Let me check.',
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          content: '{"temp": 72}',
+          toolCallId: 'tc-1',
+        },
+      ]
+
+      const uiMessages = modelMessagesToUIMessages(original)
+      const modelMessages = convertMessagesToModelMessages(uiMessages)
+
+      expect(modelMessages).toEqual(original)
+    })
+
+    it('should round-trip multimodal content array', () => {
+      const original: Array<ModelMessage> = [
+        {
+          role: 'user',
+          content: [
+            { type: 'text', content: 'What is this?' },
+            {
+              type: 'image',
+              source: { type: 'url', value: 'https://example.com/img.jpg' },
+            },
+          ],
+        },
+      ]
+
+      const uiMessages = modelMessagesToUIMessages(original)
+      const modelMessages = convertMessagesToModelMessages(uiMessages)
+
+      expect(modelMessages).toEqual(original)
+    })
+
+    it('should round-trip multi-round tool conversation', () => {
+      const original: Array<ModelMessage> = [
+        { role: 'user', content: 'Check guitars' },
+        {
+          role: 'assistant',
+          content: 'Checking.',
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getGuitars', arguments: '' },
+            },
+          ],
+        },
+        { role: 'tool', content: '[{"id":7}]', toolCallId: 'tc-1' },
+        {
+          role: 'assistant',
+          content: 'Found one!',
+          toolCalls: [
+            {
+              id: 'tc-2',
+              type: 'function',
+              function: { name: 'recommend', arguments: '{"id":7}' },
+            },
+          ],
+        },
+        {
+          role: 'tool',
+          content: '{"recommended":true}',
+          toolCallId: 'tc-2',
+        },
+        { role: 'assistant', content: 'Here is my recommendation.' },
+      ]
+
+      const uiMessages = modelMessagesToUIMessages(original)
+      const modelMessages = convertMessagesToModelMessages(uiMessages)
+
+      expect(modelMessages).toEqual(original)
+    })
+
+    it('should round-trip assistant with null content and toolCalls', () => {
+      const original: Array<ModelMessage> = [
+        {
+          role: 'assistant',
+          content: null,
+          toolCalls: [
+            {
+              id: 'tc-1',
+              type: 'function',
+              function: { name: 'getWeather', arguments: '{}' },
+            },
+          ],
+        },
+        { role: 'tool', content: '{"temp":72}', toolCallId: 'tc-1' },
+      ]
+
+      const uiMessages = modelMessagesToUIMessages(original)
+      const modelMessages = convertMessagesToModelMessages(uiMessages)
+
+      expect(modelMessages).toEqual(original)
+    })
+  })
+
+  describe('uiMessageToModelMessages - approval response handling', () => {
+    it('should emit pendingExecution marker for approved client tool', () => {
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          { type: 'text', content: 'Let me delete that for you.' },
+          {
+            type: 'tool-call',
+            id: 'call_123',
+            name: 'delete_local_data',
+            arguments: '{"key":"myKey"}',
+            state: 'approval-responded',
+            approval: {
+              id: 'approval_call_123',
+              needsApproval: true,
+              approved: true,
+            },
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      // Should produce: assistant message with text + toolCall, then a tool result
+      expect(result.length).toBeGreaterThanOrEqual(2)
+
+      // The assistant message should include the tool call
+      const assistantMsg = result.find(
+        (m) => m.role === 'assistant' && m.toolCalls,
+      )
+      expect(assistantMsg).toBeDefined()
+      expect(assistantMsg!.toolCalls).toHaveLength(1)
+      expect(assistantMsg!.toolCalls![0]!.id).toBe('call_123')
+
+      // The tool result message should have pendingExecution marker
+      const toolMsg = result.find(
+        (m) => m.role === 'tool' && m.toolCallId === 'call_123',
+      )
+      expect(toolMsg).toBeDefined()
+      const content = JSON.parse(toolMsg!.content as string)
+      expect(content.approved).toBe(true)
+      expect(content.pendingExecution).toBe(true)
+    })
+
+    it('should emit declined message for denied client tool without pendingExecution', () => {
+      const uiMessage: UIMessage = {
+        id: 'msg-1',
+        role: 'assistant',
+        parts: [
+          {
+            type: 'tool-call',
+            id: 'call_456',
+            name: 'delete_local_data',
+            arguments: '{"key":"myKey"}',
+            state: 'approval-responded',
+            approval: {
+              id: 'approval_call_456',
+              needsApproval: true,
+              approved: false,
+            },
+          },
+        ],
+      }
+
+      const result = uiMessageToModelMessages(uiMessage)
+
+      const toolMsg = result.find(
+        (m) => m.role === 'tool' && m.toolCallId === 'call_456',
+      )
+      expect(toolMsg).toBeDefined()
+      const content = JSON.parse(toolMsg!.content as string)
+      expect(content.approved).toBe(false)
+      expect(content.pendingExecution).toBeUndefined()
+      expect(content.message).toBe('User denied this action')
+    })
   })
 })
diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts
index 3358e2977..ddb7f8129 100644
--- a/packages/typescript/ai/tests/stream-processor.test.ts
+++ b/packages/typescript/ai/tests/stream-processor.test.ts
@@ -1,168 +1,1801 @@
-import { describe, expect, it } from 'vitest'
-import { StreamProcessor } from '../src/activities/chat/stream/processor'
-import type { StreamChunk } from '../src/types'
+import { type Mock, describe, expect, it, vi } from 'vitest'
+import {
+  StreamProcessor,
+  createReplayStream,
+} from '../src/activities/chat/stream/processor'
+import type { StreamProcessorEvents } from '../src/activities/chat/stream/processor'
+import type { ChunkStrategy } from '../src/activities/chat/stream/types'
+import type {
+  StreamChunk,
+  ToolCallPart,
+  ToolResultPart,
+  UIMessage,
+} from '../src/types'
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/** Create a typed StreamChunk with minimal boilerplate. */
+function chunk<T extends StreamChunk['type']>(
+  type: T,
+  fields: Omit<Extract<StreamChunk, { type: T }>, 'type' | 'timestamp'>,
+): StreamChunk {
+  return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk
+}
+
+/** Create an async iterable from a list of chunks. */
+async function* streamOf(
+  ...chunks: Array<StreamChunk>
+): AsyncIterable<StreamChunk> {
+  for (const c of chunks) {
+    yield c
+  }
+}
+
+/** Shorthand for common event sequences. */
+const ev = {
+  runStarted: (runId = 'run-1') => chunk('RUN_STARTED', { runId }),
+  textStart: (messageId = 'msg-1') =>
+    chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }),
+  textContent: (delta: string, messageId = 'msg-1') =>
+    chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }),
+  textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }),
+  toolStart: (toolCallId: string, toolName: string, index?: number) =>
+    chunk('TOOL_CALL_START', {
+      toolCallId,
+      toolName,
+      ...(index !== undefined ? { index } : {}),
+    }),
+  toolArgs: (toolCallId: string, delta: string) =>
+    chunk('TOOL_CALL_ARGS', { toolCallId, delta }),
+  toolEnd: (
+    toolCallId: string,
+    toolName: string,
+    opts?: { input?: unknown; result?: string },
+  ) => chunk('TOOL_CALL_END', { toolCallId, toolName, ...opts }),
+  runFinished: (
+    finishReason:
+      | 'stop'
+      | 'length'
+      | 'content_filter'
+      | 'tool_calls'
+      | null = 'stop',
+    runId = 'run-1',
+  ) => chunk('RUN_FINISHED', { runId, finishReason }),
+  runError: (message: string, runId = 'run-1') =>
+    chunk('RUN_ERROR', { runId, error: { message } }),
+  stepFinished: (delta: string, stepId = 'step-1') =>
+    chunk('STEP_FINISHED', { stepId, delta }),
+  custom: (name: string, data?: unknown) => chunk('CUSTOM', { name, data }),
+}
+
+/** Events object with vi.fn() mocks for assertions. */
+type MockedEvents = {
+  [K in keyof Required<StreamProcessorEvents>]: Required<StreamProcessorEvents>[K] &
+    Mock
+}
+
+/** Create a spy-laden events object for assertions. */
+function spyEvents(): MockedEvents {
+  return {
+    onMessagesChange: vi.fn(),
+    onStreamStart: vi.fn(),
+    onStreamEnd: vi.fn(),
+    onError: vi.fn(),
+    onToolCall: vi.fn(),
+    onApprovalRequest: vi.fn(),
+    onTextUpdate: vi.fn(),
+    onToolCallStateChange: vi.fn(),
+    onThinkingUpdate: vi.fn(),
+  } as MockedEvents
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
 
 describe('StreamProcessor', () => {
-  describe('handleTextMessageContentEvent', () => {
-    it('should handle TEXT_MESSAGE_CONTENT with delta', () => {
+  // ==========================================================================
+  // Constructor and options
+  // ==========================================================================
+  describe('constructor and options', () => {
+    it('should initialize with default options', () => {
       const processor = new StreamProcessor()
-      processor.startAssistantMessage()
+      expect(processor.getMessages()).toEqual([])
+      expect(processor.getCurrentAssistantMessageId()).toBeNull()
+      expect(processor.getState().done).toBe(false)
+    })
 
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        delta: 'Hello',
-        model: 'test',
-        timestamp: Date.now(),
-      } as StreamChunk)
+    it('should accept initialMessages', () => {
+      const initial: Array<UIMessage> = [
+        { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] },
+      ]
+      const processor = new StreamProcessor({ initialMessages: initial })
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(processor.getMessages()[0]!.id).toBe('u1')
+    })
 
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        delta: ' world',
-        model: 'test',
-        timestamp: Date.now(),
-      } as StreamChunk)
+    it('should not mutate the initialMessages array', () => {
+      const initial: Array<UIMessage> = [
+        { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] },
+      ]
+      const processor = new StreamProcessor({ initialMessages: initial })
+      processor.addUserMessage('second')
+      // Original array should be unchanged
+      expect(initial).toHaveLength(1)
+      expect(processor.getMessages()).toHaveLength(2)
+    })
 
-      processor.processChunk({
-        type: 'RUN_FINISHED',
-        model: 'test',
-        timestamp: Date.now(),
-        finishReason: 'stop',
-      } as StreamChunk)
+    it('should use custom chunkStrategy', () => {
+      const shouldEmit = vi.fn().mockReturnValue(false)
+      const strategy: ChunkStrategy = { shouldEmit }
 
-      const messages = processor.getMessages()
-      expect(messages).toHaveLength(1)
-      expect(messages[0]?.parts).toHaveLength(1)
-      expect(messages[0]?.parts[0]).toEqual({
+      const processor = new StreamProcessor({ chunkStrategy: strategy })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Hello'))
+
+      // Strategy was consulted
+      expect(shouldEmit).toHaveBeenCalledWith('Hello', 'Hello')
+
+      // Since strategy returned false, the text part is NOT emitted to the message
+      const msg = processor.getMessages()[0]!
+      expect(msg.parts).toHaveLength(0)
+    })
+
+    it('should use custom jsonParser', () => {
+      const parse = vi.fn().mockReturnValue({ custom: true })
+      const processor = new StreamProcessor({ jsonParser: { parse } })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'myTool'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"key":"val"}'))
+
+      expect(parse).toHaveBeenCalledWith('{"key":"val"}')
+      expect(
+        processor.getState().toolCalls.get('tc-1')?.parsedArguments,
+      ).toEqual({ custom: true })
+    })
+  })
+
+  // ==========================================================================
+  // Message management
+  // ==========================================================================
+  describe('message management', () => {
+    it('setMessages should replace messages and emit change', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      const msgs: Array<UIMessage> = [
+        {
+          id: 'a1',
+          role: 'assistant',
+          parts: [{ type: 'text', content: 'hello' }],
+        },
+      ]
+      processor.setMessages(msgs)
+
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(processor.getMessages()[0]!.id).toBe('a1')
+      expect(events.onMessagesChange).toHaveBeenCalledTimes(1)
+    })
+
+    it('setMessages should shallow-copy the input array', () => {
+      const processor = new StreamProcessor()
+      const msgs: Array<UIMessage> = [
+        { id: 'a1', role: 'assistant', parts: [] },
+      ]
+      processor.setMessages(msgs)
+      msgs.push({ id: 'a2', role: 'user', parts: [] })
+      expect(processor.getMessages()).toHaveLength(1)
+    })
+
+    it('addUserMessage with string content', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      const msg = processor.addUserMessage('Hello!')
+
+      expect(msg.role).toBe('user')
+      expect(msg.parts).toEqual([{ type: 'text', content: 'Hello!' }])
+      expect(msg.id).toBeTruthy()
+      expect(msg.createdAt).toBeInstanceOf(Date)
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(events.onMessagesChange).toHaveBeenCalledTimes(1)
+    })
+
+    it('addUserMessage with multimodal content array', () => {
+      const processor = new StreamProcessor()
+      const msg = processor.addUserMessage([
+        { type: 'text', content: 'What is this?' },
+        {
+          type: 'image',
+          source: { type: 'url', value: 'https://example.com/img.png' },
+        } as any,
+      ])
+
+      expect(msg.parts).toHaveLength(2)
+      expect(msg.parts[0]!.type).toBe('text')
+      expect(msg.parts[1]!.type).toBe('image')
+    })
+
+    it('addUserMessage with custom id', () => {
+      const processor = new StreamProcessor()
+      const msg = processor.addUserMessage('Hello', 'custom-id-42')
+      expect(msg.id).toBe('custom-id-42')
+    })
+
+    it('clearMessages should remove all messages and reset assistantMessageId', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.addUserMessage('one')
+      processor.addUserMessage('two')
+      events.onMessagesChange.mockClear()
+
+      processor.clearMessages()
+      expect(processor.getMessages()).toHaveLength(0)
+      expect(processor.getCurrentAssistantMessageId()).toBeNull()
+      expect(events.onMessagesChange).toHaveBeenCalledTimes(1)
+    })
+
+    it('removeMessagesAfter should truncate after the given index', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.addUserMessage('zero')
+      processor.addUserMessage('one')
+      processor.addUserMessage('two')
+      events.onMessagesChange.mockClear()
+
+      processor.removeMessagesAfter(0)
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(processor.getMessages()[0]!.parts[0]).toEqual({
         type: 'text',
-        content: 'Hello world',
+        content: 'zero',
       })
+      expect(events.onMessagesChange).toHaveBeenCalledTimes(1)
     })
 
-    it('should handle TEXT_MESSAGE_CONTENT with undefined delta (issue #257)', () => {
+    it('toModelMessages should convert all messages', () => {
       const processor = new StreamProcessor()
-      processor.startAssistantMessage()
+      processor.addUserMessage('Hello')
 
-      // Simulate a chunk where delta is undefined (which can happen in practice)
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        delta: undefined,
-        content: 'Hello',
-        model: 'test',
-        timestamp: Date.now(),
-      } as unknown as StreamChunk)
+      // Simulate an assistant response
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.textContent('Hi there'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
 
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        delta: undefined,
-        content: 'Hello world',
-        model: 'test',
-        timestamp: Date.now(),
-      } as unknown as StreamChunk)
+      const modelMsgs = processor.toModelMessages()
+      expect(modelMsgs.length).toBeGreaterThanOrEqual(2)
+      expect(modelMsgs[0]!.role).toBe('user')
+      expect(modelMsgs[1]!.role).toBe('assistant')
+    })
+  })
 
-      processor.processChunk({
-        type: 'RUN_FINISHED',
-        model: 'test',
-        timestamp: Date.now(),
-        finishReason: 'stop',
-      } as StreamChunk)
+  // ==========================================================================
+  // process() full stream
+  // ==========================================================================
+  describe('process() full stream', () => {
+    it('should consume async iterable and return ProcessorResult', async () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      const result = await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.textStart(),
+          ev.textContent('Hello'),
+          ev.textContent(' world!'),
+          ev.textEnd(),
+          ev.runFinished('stop'),
+        ),
+      )
+
+      expect(result.content).toBe('Hello world!')
+      expect(result.finishReason).toBe('stop')
+      expect(result.toolCalls).toBeUndefined()
+      expect(result.thinking).toBeUndefined()
+    })
+
+    it('should return toolCalls in ProcessorResult when tool calls present', async () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      const result = await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.toolStart('tc-1', 'getWeather'),
+          ev.toolArgs('tc-1', '{"city":"NYC"}'),
+          ev.toolEnd('tc-1', 'getWeather', { input: { city: 'NYC' } }),
+          ev.runFinished('tool_calls'),
+        ),
+      )
+
+      expect(result.content).toBe('')
+      expect(result.finishReason).toBe('tool_calls')
+      expect(result.toolCalls).toHaveLength(1)
+      expect(result.toolCalls![0]!.function.name).toBe('getWeather')
+      expect(result.toolCalls![0]!.function.arguments).toBe('{"city":"NYC"}')
+    })
+
+    it('should return thinking in ProcessorResult when thinking present', async () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      const result = await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.stepFinished('Let me think'),
+          ev.stepFinished('...'),
+          ev.textStart(),
+          ev.textContent('Answer'),
+          ev.textEnd(),
+          ev.runFinished('stop'),
+        ),
+      )
+
+      expect(result.content).toBe('Answer')
+      expect(result.thinking).toBe('Let me think...')
+    })
+
+    it('should call finalizeStream after consuming the stream', async () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.textStart(),
+          ev.textContent('Done'),
+          ev.textEnd(),
+          ev.runFinished('stop'),
+        ),
+      )
+
+      // onStreamEnd should have been called (by finalizeStream)
+      expect(events.onStreamEnd).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  // ==========================================================================
+  // Single-shot text
+  // ==========================================================================
+  describe('single-shot text', () => {
+    it('should handle full text-only flow: RUN_STARTED -> TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT* -> TEXT_MESSAGE_END -> RUN_FINISHED(stop)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.textContent(' world'))
+      processor.processChunk(ev.textContent('!'))
+      processor.processChunk(ev.textEnd())
+      processor.processChunk(ev.runFinished('stop'))
+
+      processor.finalizeStream()
 
       const messages = processor.getMessages()
       expect(messages).toHaveLength(1)
-      expect(messages[0]?.parts).toHaveLength(1)
-      // Should NOT contain "undefined" string
-      expect(messages[0]?.parts[0]).toEqual({
+      expect(messages[0]!.role).toBe('assistant')
+      expect(messages[0]!.parts).toHaveLength(1)
+      expect(messages[0]!.parts[0]).toEqual({
         type: 'text',
-        content: 'Hello world',
+        content: 'Hello world!',
       })
+
+      const state = processor.getState()
+      expect(state.content).toBe('Hello world!')
+      expect(state.finishReason).toBe('stop')
+      expect(state.done).toBe(true)
     })
 
-    it('should handle TEXT_MESSAGE_CONTENT with empty delta', () => {
+    it('should handle TEXT_MESSAGE_CONTENT with delta (existing test preserved)', () => {
       const processor = new StreamProcessor()
-      processor.startAssistantMessage()
-
-      // Empty delta should fall back to content
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        delta: '',
-        content: 'Hello',
-        model: 'test',
-        timestamp: Date.now(),
-      } as StreamChunk)
+      processor.prepareAssistantMessage()
 
-      processor.processChunk({
-        type: 'RUN_FINISHED',
-        model: 'test',
-        timestamp: Date.now(),
-        finishReason: 'stop',
-      } as StreamChunk)
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.textContent(' world'))
+      processor.processChunk(ev.runFinished('stop'))
 
       const messages = processor.getMessages()
       expect(messages).toHaveLength(1)
       expect(messages[0]?.parts).toHaveLength(1)
       expect(messages[0]?.parts[0]).toEqual({
         type: 'text',
-        content: 'Hello',
+        content: 'Hello world',
       })
     })
+  })
 
-    it('should handle TEXT_MESSAGE_CONTENT with only content (no delta)', () => {
+  // ==========================================================================
+  // Single-shot tool call
+  // ==========================================================================
+  describe('single-shot tool call', () => {
+    it('should handle tool call only (no text)', () => {
       const processor = new StreamProcessor()
-      processor.startAssistantMessage()
+      processor.prepareAssistantMessage()
 
-      // Some servers may only send content without delta
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        content: 'Hello',
-        model: 'test',
-        timestamp: Date.now(),
-      } as unknown as StreamChunk)
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.toolStart('call_1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('call_1', '{"city":'))
+      processor.processChunk(ev.toolArgs('call_1', '"NYC"}'))
+      processor.processChunk(ev.toolEnd('call_1', 'getWeather'))
+      processor.processChunk(ev.runFinished('tool_calls'))
 
-      processor.processChunk({
-        type: 'TEXT_MESSAGE_CONTENT',
-        messageId: 'msg-1',
-        content: 'Hello world',
-        model: 'test',
-        timestamp: Date.now(),
-      } as unknown as StreamChunk)
+      processor.finalizeStream()
 
-      processor.processChunk({
-        type: 'RUN_FINISHED',
-        model: 'test',
-        timestamp: Date.now(),
-        finishReason: 'stop',
-      } as StreamChunk)
+      const messages = processor.getMessages()
+      expect(messages).toHaveLength(1)
+      expect(messages[0]!.parts).toHaveLength(1)
+
+      const toolCallPart = messages[0]!.parts[0] as ToolCallPart
+      expect(toolCallPart.type).toBe('tool-call')
+      expect(toolCallPart.id).toBe('call_1')
+      expect(toolCallPart.name).toBe('getWeather')
+      expect(toolCallPart.arguments).toBe('{"city":"NYC"}')
+      expect(toolCallPart.state).toBe('input-complete')
+
+      const state = processor.getState()
+      expect(state.content).toBe('')
+      expect(state.finishReason).toBe('tool_calls')
+    })
+
+    it('should handle text then tool call', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent('Let me check.'))
+      processor.processChunk(ev.textEnd())
+      processor.processChunk(ev.toolStart('call_1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('call_1', '{"city":"NYC"}'))
+      processor.processChunk(
+        ev.toolEnd('call_1', 'getWeather', { input: { city: 'NYC' } }),
+      )
+      processor.processChunk(ev.runFinished('tool_calls'))
+
+      processor.finalizeStream()
 
       const messages = processor.getMessages()
       expect(messages).toHaveLength(1)
-      expect(messages[0]?.parts).toHaveLength(1)
-      expect(messages[0]?.parts[0]).toEqual({
+      expect(messages[0]!.parts).toHaveLength(2)
+      expect(messages[0]!.parts[0]).toEqual({
         type: 'text',
-        content: 'Hello world',
+        content: 'Let me check.',
       })
+
+      const tcPart = messages[0]!.parts[1] as ToolCallPart
+      expect(tcPart.type).toBe('tool-call')
+      expect(tcPart.state).toBe('input-complete')
     })
 
-    it('should have empty parts when no TEXT_MESSAGE_CONTENT is received', () => {
+    it('should track state transitions: awaiting-input -> input-streaming -> input-complete', () => {
       const processor = new StreamProcessor()
-      processor.startAssistantMessage()
+      processor.prepareAssistantMessage()
 
-      // Only RUN_FINISHED without any text content
-      processor.processChunk({
-        type: 'RUN_FINISHED',
-        model: 'test',
-        timestamp: Date.now(),
-        finishReason: 'stop',
-      } as StreamChunk)
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'awaiting-input',
+      )
+
+      processor.processChunk(ev.toolArgs('tc-1', '{"city":'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-streaming',
+      )
+
+      processor.processChunk(ev.toolArgs('tc-1', '"NYC"}'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-streaming',
+      )
+
+      processor.processChunk(ev.toolEnd('tc-1', 'getWeather'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-complete',
+      )
+    })
+
+    it('should use chunk.input as canonical parsed arguments (existing test preserved)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', {
+          input: { city: 'NYC', unit: 'celsius' },
+        }),
+      )
+
+      const state = processor.getState()
+      const toolCall = state.toolCalls.get('tc-1')
+      expect(toolCall?.state).toBe('input-complete')
+      expect(toolCall?.parsedArguments).toEqual({
+        city: 'NYC',
+        unit: 'celsius',
+      })
+    })
+
+    it('should default tool call index to toolCalls.size when index is not provided', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'toolA'))
+      processor.processChunk(ev.toolStart('tc-2', 'toolB'))
+
+      const state = processor.getState()
+      expect(state.toolCalls.get('tc-1')?.index).toBe(0)
+      expect(state.toolCalls.get('tc-2')?.index).toBe(1)
+    })
+
+    it('should use provided index when available', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'toolA', 5))
+
+      expect(processor.getState().toolCalls.get('tc-1')?.index).toBe(5)
+    })
+  })
+
+  // ==========================================================================
+  // Parallel tool calls
+  // ==========================================================================
+  describe('parallel tool calls', () => {
+    it('should handle interleaved parallel tool calls', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // Interleaved: both STARTs, then both ARGS, then both ENDs
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.toolStart('call_1', 'getWeather', 0))
+      processor.processChunk(ev.toolStart('call_2', 'getTime', 1))
+      processor.processChunk(ev.toolArgs('call_1', '{"city":"NYC"}'))
+      processor.processChunk(ev.toolArgs('call_2', '{"tz":"EST"}'))
+      processor.processChunk(ev.toolEnd('call_1', 'getWeather'))
+      processor.processChunk(ev.toolEnd('call_2', 'getTime'))
+      processor.processChunk(ev.runFinished('tool_calls'))
+
+      processor.finalizeStream()
 
       const messages = processor.getMessages()
       expect(messages).toHaveLength(1)
-      // Parts should be empty when no content was received
-      expect(messages[0]?.parts).toHaveLength(0)
+      expect(messages[0]!.parts).toHaveLength(2)
+
+      const tc1 = messages[0]!.parts[0] as ToolCallPart
+      const tc2 = messages[0]!.parts[1] as ToolCallPart
+      expect(tc1.id).toBe('call_1')
+      expect(tc1.name).toBe('getWeather')
+      expect(tc1.arguments).toBe('{"city":"NYC"}')
+      expect(tc1.state).toBe('input-complete')
+
+      expect(tc2.id).toBe('call_2')
+      expect(tc2.name).toBe('getTime')
+      expect(tc2.arguments).toBe('{"tz":"EST"}')
+      expect(tc2.state).toBe('input-complete')
+    })
+
+    it('should handle sequential parallel tool calls', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // Sequential: one tool fully completes before the next starts
+      processor.processChunk(ev.toolStart('call_1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('call_1', '{"city":"NYC"}'))
+      processor.processChunk(ev.toolEnd('call_1', 'getWeather'))
+      processor.processChunk(ev.toolStart('call_2', 'getTime'))
+      processor.processChunk(ev.toolArgs('call_2', '{"tz":"EST"}'))
+      processor.processChunk(ev.toolEnd('call_2', 'getTime'))
+      processor.processChunk(ev.runFinished('tool_calls'))
+
+      processor.finalizeStream()
+
+      const messages = processor.getMessages()
+      expect(messages[0]!.parts).toHaveLength(2)
+      expect((messages[0]!.parts[0] as ToolCallPart).id).toBe('call_1')
+      expect((messages[0]!.parts[1] as ToolCallPart).id).toBe('call_2')
+    })
+
+    it('should track tool calls by toolCallId in Map, maintaining order', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('call_1', 'getWeather'))
+      processor.processChunk(ev.toolStart('call_2', 'getTime'))
+
+      const state = processor.getState()
+      expect(state.toolCallOrder).toEqual(['call_1', 'call_2'])
+      expect(state.toolCalls.size).toBe(2)
+    })
+  })
+
+  // ==========================================================================
+  // Text-tool interleaving
+  // ==========================================================================
+  describe('text-tool interleaving', () => {
+    it('should reset segment text accumulation on TEXT_MESSAGE_START (existing test preserved)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent('First segment'))
+      processor.processChunk(ev.toolStart('tc-1', 'search'))
+      processor.processChunk(ev.toolEnd('tc-1', 'search', { input: {} }))
+      processor.processChunk(ev.textStart('msg-2'))
+      processor.processChunk(ev.textContent('Second segment', 'msg-2'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      const state = processor.getState()
+      expect(state.content).toBe('First segmentSecond segment')
+    })
+
+    it('should produce text -> tool-call -> tool-result -> text (4-part ordering)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // First adapter stream: text + tool call
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent('Checking weather...'))
+      processor.processChunk(ev.textEnd())
+      processor.processChunk(ev.toolStart('call_1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('call_1', '{"city":"NYC"}'))
+      processor.processChunk(ev.toolEnd('call_1', 'getWeather'))
+      processor.processChunk(ev.runFinished('tool_calls'))
+
+      // TextEngine executes tool, yields result
+      processor.processChunk(
+        ev.toolEnd('call_1', 'getWeather', { result: '{"temp":"72F"}' }),
+      )
+
+      // Second adapter stream: more text
+      processor.processChunk(ev.textStart('msg-2'))
+      processor.processChunk(ev.textContent("It's 72F in NYC.", 'msg-2'))
+      processor.processChunk(ev.textEnd('msg-2'))
+      processor.processChunk(ev.runFinished('stop'))
+
+      processor.finalizeStream()
+
+      const messages = processor.getMessages()
+      expect(messages).toHaveLength(1)
+      const parts = messages[0]!.parts
+
+      // Expected: [text, tool-call, tool-result, text]
+      expect(parts).toHaveLength(4)
+      expect(parts[0]!.type).toBe('text')
+      expect((parts[0] as any).content).toBe('Checking weather...')
+
+      expect(parts[1]!.type).toBe('tool-call')
+      expect((parts[1] as ToolCallPart).id).toBe('call_1')
+
+      expect(parts[2]!.type).toBe('tool-result')
+      expect((parts[2] as ToolResultPart).toolCallId).toBe('call_1')
+
+      expect(parts[3]!.type).toBe('text')
+      expect((parts[3] as any).content).toBe("It's 72F in NYC.")
+    })
+
+    it('should create two separate TextParts for text before and after tool calls', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent('Before'))
+      processor.processChunk(ev.textEnd())
+      processor.processChunk(ev.toolStart('tc-1', 'tool'))
+      processor.processChunk(ev.toolEnd('tc-1', 'tool'))
+      processor.processChunk(ev.textStart('msg-2'))
+      processor.processChunk(ev.textContent('After', 'msg-2'))
+      processor.processChunk(ev.textEnd('msg-2'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      const parts = processor.getMessages()[0]!.parts
+      const textParts = parts.filter((p) => p.type === 'text')
+      expect(textParts).toHaveLength(2)
+      expect((textParts[0] as any).content).toBe('Before')
+      expect((textParts[1] as any).content).toBe('After')
+    })
+  })
+
+  // ==========================================================================
+  // Thinking/reasoning
+  // ==========================================================================
+  describe('thinking/reasoning', () => {
+    it('should accumulate STEP_FINISHED deltas into thinking content', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.stepFinished('Let me think'))
+      processor.processChunk(ev.stepFinished(' about this...'))
+
+      const messages = processor.getMessages()
+      expect(messages).toHaveLength(1)
+      const thinkingPart = messages[0]!.parts.find((p) => p.type === 'thinking')
+      expect(thinkingPart).toBeDefined()
+      expect((thinkingPart as any).content).toBe('Let me think about this...')
+    })
+
+    it('should handle thinking then text flow', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runStarted())
+      processor.processChunk(ev.stepFinished('Let me think'))
+      processor.processChunk(ev.stepFinished(' about this...'))
+      processor.processChunk(ev.textStart())
+      processor.processChunk(ev.textContent("Here's my answer."))
+      processor.processChunk(ev.textEnd())
+      processor.processChunk(ev.runFinished('stop'))
+
+      processor.finalizeStream()
+
+      const parts = processor.getMessages()[0]!.parts
+      expect(parts).toHaveLength(2)
+      expect(parts[0]!.type).toBe('thinking')
+      expect((parts[0] as any).content).toBe('Let me think about this...')
+      expect(parts[1]!.type).toBe('text')
+      expect((parts[1] as any).content).toBe("Here's my answer.")
+
+      const state = processor.getState()
+      expect(state.thinking).toBe('Let me think about this...')
+      expect(state.content).toBe("Here's my answer.")
+    })
+
+    it('should create assistant message lazily on thinking content (existing test preserved)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      expect(processor.getMessages()).toHaveLength(0)
+
+      processor.processChunk(ev.stepFinished('thinking...'))
+
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(
+        processor.getMessages()[0]?.parts.some((p) => p.type === 'thinking'),
+      ).toBe(true)
+    })
+
+    it('should update a single ThinkingPart in-place', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.stepFinished('A'))
+      processor.processChunk(ev.stepFinished('B'))
+      processor.processChunk(ev.stepFinished('C'))
+
+      // Only one thinking part, not three
+      const parts = processor.getMessages()[0]!.parts
+      const thinkingParts = parts.filter((p) => p.type === 'thinking')
+      expect(thinkingParts).toHaveLength(1)
+      expect((thinkingParts[0] as any).content).toBe('ABC')
+    })
+  })
+
+  // ==========================================================================
+  // Tool results
+  // ==========================================================================
+  describe('tool results', () => {
+    it('should update tool-call part output field when TOOL_CALL_END has a result (existing test)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"city":"NYC"}'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', {
+          input: { city: 'NYC' },
+          result: '{"temp":72}',
+        }),
+      )
+
+      const messages = processor.getMessages()
+      const toolCallPart = messages[0]?.parts.find(
+        (p) => p.type === 'tool-call',
+      ) as ToolCallPart
+      expect((toolCallPart as any).output).toEqual({ temp: 72 })
+
+      const toolResultPart = messages[0]?.parts.find(
+        (p) => p.type === 'tool-result',
+      ) as ToolResultPart
+      expect(toolResultPart).toBeDefined()
+      expect(toolResultPart.content).toBe('{"temp":72}')
+      expect(toolResultPart.state).toBe('complete')
+    })
+
+    it('should not update tool-call output when TOOL_CALL_END has no result (existing test)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', { input: { city: 'NYC' } }),
+      )
+
+      const messages = processor.getMessages()
+      const toolCallPart = messages[0]?.parts.find(
+        (p) => p.type === 'tool-call',
+      )
+      expect((toolCallPart as any).output).toBeUndefined()
+      expect(
+        messages[0]?.parts.find((p) => p.type === 'tool-result'),
+      ).toBeUndefined()
+    })
+
+    it('should handle non-JSON result string gracefully (existing test)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getText'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getText', {
+          input: {},
+          result: 'plain text result',
+        }),
+      )
+
+      const toolCallPart = processor
+        .getMessages()[0]
+        ?.parts.find((p) => p.type === 'tool-call')
+      expect((toolCallPart as any).output).toBe('plain text result')
+    })
+
+    it('addToolResult should create tool-result part and set output on tool-call part', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', { input: { city: 'NYC' } }),
+      )
+      events.onMessagesChange.mockClear()
+
+      processor.addToolResult('tc-1', { temp: 72 })
+
+      const messages = processor.getMessages()
+      const toolCallPart = messages[0]!.parts.find(
+        (p) => p.type === 'tool-call',
+      ) as ToolCallPart
+      expect((toolCallPart as any).output).toEqual({ temp: 72 })
+
+      const toolResultPart = messages[0]!.parts.find(
+        (p) => p.type === 'tool-result',
+      ) as ToolResultPart
+      expect(toolResultPart).toBeDefined()
+      expect(toolResultPart.content).toBe('{"temp":72}')
+      expect(toolResultPart.state).toBe('complete')
+
+      expect(events.onMessagesChange).toHaveBeenCalled()
+    })
+
+    it('addToolResult with string output should store as-is', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getText'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getText', { input: {} }))
+
+      processor.addToolResult('tc-1', 'plain string output')
+
+      const toolResultPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-result') as ToolResultPart
+      expect(toolResultPart.content).toBe('plain string output')
+    })
+
+    it('addToolResult with error should set error state', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getWeather', { input: {} }))
+
+      processor.addToolResult('tc-1', null, 'Network error')
+
+      const toolCallPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-call') as ToolCallPart
+      expect((toolCallPart as any).output).toEqual({ error: 'Network error' })
+
+      const toolResultPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-result') as ToolResultPart
+      expect(toolResultPart.state).toBe('error')
+      expect(toolResultPart.error).toBe('Network error')
+    })
+
+    it('addToolResult with missing toolCallId should warn and no-op', () => {
+      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+      const processor = new StreamProcessor()
+
+      processor.addToolResult('nonexistent-id', { temp: 72 })
+
+      expect(warnSpy).toHaveBeenCalledWith(
+        expect.stringContaining('nonexistent-id'),
+      )
+      expect(processor.getMessages()).toHaveLength(0)
+      warnSpy.mockRestore()
+    })
+  })
+
+  // ==========================================================================
+  // Client tools (CUSTOM events)
+  // ==========================================================================
+  describe('client tools (CUSTOM events)', () => {
+    it('should fire onToolCall when tool-input-available CUSTOM event arrives', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      // Create a tool call first
+      processor.processChunk(ev.toolStart('tc-1', 'clientTool'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'clientTool', { input: { query: 'test' } }),
+      )
+
+      processor.processChunk(
+        ev.custom('tool-input-available', {
+          toolCallId: 'tc-1',
+          toolName: 'clientTool',
+          input: { query: 'test' },
+        }),
+      )
+
+      expect(events.onToolCall).toHaveBeenCalledTimes(1)
+      expect(events.onToolCall).toHaveBeenCalledWith({
+        toolCallId: 'tc-1',
+        toolName: 'clientTool',
+        input: { query: 'test' },
+      })
+    })
+
+    it('should not fire onToolCall for CUSTOM events with no data', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.custom('tool-input-available'))
+
+      expect(events.onToolCall).not.toHaveBeenCalled()
+    })
+
+    it('should not fire onToolCall for unrelated CUSTOM events', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.custom('some-other-event', { foo: 'bar' }))
+
+      expect(events.onToolCall).not.toHaveBeenCalled()
+    })
+  })
+
+  // ==========================================================================
+  // Approval flow
+  // ==========================================================================
+  describe('approval flow', () => {
+    it('should handle approval-requested CUSTOM event', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      // Create a tool call
+      processor.processChunk(ev.toolStart('tc-1', 'dangerousTool'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'dangerousTool', { input: { action: 'delete' } }),
+      )
+
+      // Fire approval request
+      processor.processChunk(
+        ev.custom('approval-requested', {
+          toolCallId: 'tc-1',
+          toolName: 'dangerousTool',
+          input: { action: 'delete' },
+          approval: { id: 'approval-1', needsApproval: true },
+        }),
+      )
+
+      // Check that tool-call part has approval state
+      const toolCallPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-call') as ToolCallPart
+      expect(toolCallPart.state).toBe('approval-requested')
+      expect(toolCallPart.approval).toEqual({
+        id: 'approval-1',
+        needsApproval: true,
+      })
+
+      // Check callback was fired
+      expect(events.onApprovalRequest).toHaveBeenCalledWith({
+        toolCallId: 'tc-1',
+        toolName: 'dangerousTool',
+        input: { action: 'delete' },
+        approvalId: 'approval-1',
+      })
+    })
+
+    it('addToolApprovalResponse should approve a tool call', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'dangerousTool'))
+      processor.processChunk(ev.toolEnd('tc-1', 'dangerousTool', { input: {} }))
+      processor.processChunk(
+        ev.custom('approval-requested', {
+          toolCallId: 'tc-1',
+          toolName: 'dangerousTool',
+          input: {},
+          approval: { id: 'approval-1', needsApproval: true },
+        }),
+      )
+
+      events.onMessagesChange.mockClear()
+      processor.addToolApprovalResponse('approval-1', true)
+
+      const toolCallPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-call') as ToolCallPart
+      expect(toolCallPart.state).toBe('approval-responded')
+      expect(toolCallPart.approval?.approved).toBe(true)
+      expect(events.onMessagesChange).toHaveBeenCalled()
+    })
+
+    it('addToolApprovalResponse should deny a tool call', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'dangerousTool'))
+      processor.processChunk(ev.toolEnd('tc-1', 'dangerousTool', { input: {} }))
+      processor.processChunk(
+        ev.custom('approval-requested', {
+          toolCallId: 'tc-1',
+          toolName: 'dangerousTool',
+          input: {},
+          approval: { id: 'approval-1', needsApproval: true },
+        }),
+      )
+
+      processor.addToolApprovalResponse('approval-1', false)
+
+      const toolCallPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-call') as ToolCallPart
+      expect(toolCallPart.state).toBe('approval-responded')
+      expect(toolCallPart.approval?.approved).toBe(false)
+    })
+
+    it('should not fire onApprovalRequest for approval-requested without data', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.custom('approval-requested'))
+      expect(events.onApprovalRequest).not.toHaveBeenCalled()
+    })
+  })
+
+  // ==========================================================================
+  // areAllToolsComplete
+  // ==========================================================================
+  describe('areAllToolsComplete', () => {
+    it('should return true when there is no assistant message', () => {
+      const processor = new StreamProcessor()
+      expect(processor.areAllToolsComplete()).toBe(true)
+    })
+
+    it('should return true when there are no tool calls', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(processor.areAllToolsComplete()).toBe(true)
+    })
+
+    it('should return false when tool calls are pending (no result, no output)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getWeather', { input: {} }))
+
+      expect(processor.areAllToolsComplete()).toBe(false)
+    })
+
+    it('should return true when tool call has corresponding tool-result part (server tool)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', { input: {}, result: '{"temp":72}' }),
+      )
+
+      expect(processor.areAllToolsComplete()).toBe(true)
+    })
+
+    it('should return true when client tool has output (via addToolResult)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.toolStart('tc-1', 'clientTool'))
+      processor.processChunk(ev.toolEnd('tc-1', 'clientTool', { input: {} }))
+
+      processor.addToolResult('tc-1', { data: 'result' })
+
+      expect(processor.areAllToolsComplete()).toBe(true)
+    })
+
+    it('should return true when tool call is in approval-responded state', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.toolStart('tc-1', 'dangerousTool'))
+      processor.processChunk(ev.toolEnd('tc-1', 'dangerousTool', { input: {} }))
+      processor.processChunk(
+        ev.custom('approval-requested', {
+          toolCallId: 'tc-1',
+          toolName: 'dangerousTool',
+          input: {},
+          approval: { id: 'a1', needsApproval: true },
+        }),
+      )
+      processor.addToolApprovalResponse('a1', true)
+
+      expect(processor.areAllToolsComplete()).toBe(true)
+    })
+
+    it('should return false when some tool calls are complete but not all', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', { input: {}, result: '{"temp":72}' }),
+      )
+      processor.processChunk(ev.toolStart('tc-2', 'getTime'))
+      processor.processChunk(ev.toolEnd('tc-2', 'getTime', { input: {} }))
+
+      // tc-1 has a result, but tc-2 does not
+      expect(processor.areAllToolsComplete()).toBe(false)
+    })
+  })
+
+  // ==========================================================================
+  // Lazy assistant message creation (existing tests preserved)
+  // ==========================================================================
+  describe('lazy assistant message creation', () => {
+    it('should not create assistant message when no content arrives', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(processor.getMessages()).toHaveLength(0)
+      expect(processor.getCurrentAssistantMessageId()).toBeNull()
+    })
+
+    it('should create assistant message lazily on first text content', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      expect(processor.getMessages()).toHaveLength(0)
+      expect(processor.getCurrentAssistantMessageId()).toBeNull()
+
+      processor.processChunk(ev.textContent('Hello!'))
+
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(processor.getCurrentAssistantMessageId()).not.toBeNull()
+      expect(processor.getMessages()[0]?.role).toBe('assistant')
+    })
+
+    it('should create assistant message lazily on first tool call', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      expect(processor.getMessages()).toHaveLength(0)
+
+      processor.processChunk(ev.toolStart('tc-1', 'getGuitars'))
+
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(
+        processor.getMessages()[0]?.parts.some((p) => p.type === 'tool-call'),
+      ).toBe(true)
+    })
+
+    it('should create assistant message lazily on error', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      expect(processor.getMessages()).toHaveLength(0)
+
+      processor.processChunk(ev.runError('Something went wrong'))
+      processor.finalizeStream()
+
+      const messages = processor.getMessages()
+      expect(messages).toHaveLength(1)
+      expect(messages[0]?.role).toBe('assistant')
+      expect(messages[0]?.parts).toHaveLength(0)
+    })
+
+    it('should create assistant message lazily on thinking content', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // No message yet
+      expect(processor.getMessages()).toHaveLength(0)
+
+      processor.processChunk({
+        type: 'STEP_FINISHED',
+        stepId: 'step-1',
+        model: 'test',
+        timestamp: Date.now(),
+        delta: 'thinking...',
+        content: 'thinking...',
+      } as StreamChunk)
+
+      // Now the message exists with thinking content
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(
+        processor.getMessages()[0]?.parts.some((p) => p.type === 'thinking'),
+      ).toBe(true)
+    })
+
+    it('should not create assistant message during empty multi-turn continuation', () => {
+      const processor = new StreamProcessor()
+
+      processor.addUserMessage('recommend a guitar')
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getGuitars'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getGuitars', { input: {} }))
+      processor.processChunk(ev.runFinished('tool_calls'))
+      processor.finalizeStream()
+
+      expect(processor.getMessages()).toHaveLength(2)
+
+      // Auto-continuation: prepare but no content
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(processor.getMessages()).toHaveLength(2)
+      expect(processor.getMessages()[1]?.role).toBe('assistant')
+    })
+
+    it('should support deprecated startAssistantMessage for backwards compatibility', () => {
+      const processor = new StreamProcessor()
+      const messageId = processor.startAssistantMessage()
+
+      expect(messageId).toBeTruthy()
+      expect(processor.getMessages()).toHaveLength(1)
+      expect(processor.getMessages()[0]?.id).toBe(messageId)
+    })
+  })
+
+  // ==========================================================================
+  // Edge cases
+  // ==========================================================================
+  describe('edge cases', () => {
+    it('duplicate TOOL_CALL_START with same toolCallId should be a no-op', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather')) // duplicate
+
+      const state = processor.getState()
+      expect(state.toolCalls.size).toBe(1)
+      expect(state.toolCallOrder).toEqual(['tc-1'])
+
+      // Only one tool-call part in UIMessage
+      const toolParts = processor
+        .getMessages()[0]!
+        .parts.filter((p) => p.type === 'tool-call')
+      expect(toolParts).toHaveLength(1)
+    })
+
+    it('TOOL_CALL_ARGS for unknown toolCallId should be silently dropped', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // Send args without a preceding START
+      processor.processChunk(ev.toolArgs('unknown-id', '{"key":"val"}'))
+
+      // No tool calls in state
+      expect(processor.getState().toolCalls.size).toBe(0)
+      // No messages created (no content-bearing event)
+      expect(processor.getMessages()).toHaveLength(0)
+    })
+
+    it('empty delta in TOOL_CALL_ARGS should not transition from awaiting-input', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'myTool'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'awaiting-input',
+      )
+
+      // Empty delta
+      processor.processChunk(ev.toolArgs('tc-1', ''))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'awaiting-input',
+      )
+
+      // Non-empty delta transitions
+      processor.processChunk(ev.toolArgs('tc-1', '{"key":'))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-streaming',
+      )
+    })
+
+    it('missing TOOL_CALL_END should be caught by RUN_FINISHED safety net', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"city":"NYC"}'))
+      // No TOOL_CALL_END!
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-streaming',
+      )
+
+      processor.processChunk(ev.runFinished('tool_calls'))
+      // Safety net should force-complete
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-complete',
+      )
+    })
+
+    it('TOOL_CALL_END for already input-complete tool call should be a no-op for state', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getWeather', { input: {} }))
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-complete',
+      )
+
+      // Second TOOL_CALL_END with result still processes the result
+      processor.processChunk(
+        ev.toolEnd('tc-1', 'getWeather', { result: '{"temp":72}' }),
+      )
+
+      // Should have added tool-result part
+      const resultPart = processor
+        .getMessages()[0]!
+        .parts.find((p) => p.type === 'tool-result')
+      expect(resultPart).toBeDefined()
+    })
+
+    it('ignored event types (RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA) should not crash', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      // These should all be silently ignored
+      processor.processChunk(chunk('RUN_STARTED', { runId: 'run-1' }))
+      processor.processChunk(chunk('TEXT_MESSAGE_END', { messageId: 'msg-1' }))
+      processor.processChunk(chunk('STEP_STARTED', { stepId: 'step-1' }))
+      processor.processChunk(chunk('STATE_SNAPSHOT', { state: { key: 'val' } }))
+      processor.processChunk(chunk('STATE_DELTA', { delta: { key: 'val' } }))
+
+      // No messages created (none of these are content-bearing)
+      expect(processor.getMessages()).toHaveLength(0)
+    })
+
+    it('RUN_ERROR with empty message should use fallback', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runError(''))
+
+      expect(events.onError).toHaveBeenCalledTimes(1)
+      const errorArg = events.onError.mock.calls[0]![0]
+      expect(errorArg.message).toBe('An error occurred')
+    })
+  })
+
+  // ==========================================================================
+  // Event callbacks
+  // ==========================================================================
+  describe('event callbacks', () => {
+    it('onMessagesChange should be called on every message state change', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.textContent(' world'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      // ensureAssistantMessage (creates msg) + textContent emission + textContent emission
+      // + runFinished (no tool calls, no change) + finalizeStream (no pending text, onStreamEnd no messages change)
+      // The exact count depends on internal emission logic; let's verify it was called
+      expect(events.onMessagesChange.mock.calls.length).toBeGreaterThanOrEqual(
+        2,
+      )
+    })
+
+    it('onStreamStart should fire on first content-bearing chunk', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      // Non-content chunk should NOT trigger onStreamStart
+      processor.processChunk(ev.runStarted())
+      expect(events.onStreamStart).not.toHaveBeenCalled()
+
+      // First content chunk triggers it
+      processor.processChunk(ev.textContent('Hi'))
+      expect(events.onStreamStart).toHaveBeenCalledTimes(1)
+
+      // Subsequent content does NOT re-trigger
+      processor.processChunk(ev.textContent(' there'))
+      expect(events.onStreamStart).toHaveBeenCalledTimes(1)
+    })
+
+    it('onStreamEnd should fire during finalizeStream with the assistant message', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Done'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(events.onStreamEnd).toHaveBeenCalledTimes(1)
+      const msg = events.onStreamEnd.mock.calls[0]![0]
+      expect(msg.role).toBe('assistant')
+      expect(msg.parts[0]).toEqual({ type: 'text', content: 'Done' })
+    })
+
+    it('onStreamEnd should NOT fire if no assistant message was created', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(events.onStreamEnd).not.toHaveBeenCalled()
+    })
+
+    it('onError should fire on RUN_ERROR', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.runError('API rate limited'))
+
+      expect(events.onError).toHaveBeenCalledTimes(1)
+      expect(events.onError.mock.calls[0]![0]).toBeInstanceOf(Error)
+      expect(events.onError.mock.calls[0]![0].message).toBe('API rate limited')
+    })
+
+    it('onTextUpdate should fire for each text emission', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.textContent(' world'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      // With ImmediateStrategy, each content chunk triggers onTextUpdate
+      expect(events.onTextUpdate).toHaveBeenCalledTimes(2)
+      const msgId = processor.getCurrentAssistantMessageId()!
+      expect(events.onTextUpdate).toHaveBeenCalledWith(msgId, 'Hello')
+      expect(events.onTextUpdate).toHaveBeenCalledWith(msgId, 'Hello world')
+    })
+
+    it('onToolCallStateChange should fire on state transitions', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"city":"NYC"}'))
+      processor.processChunk(ev.toolEnd('tc-1', 'getWeather'))
+
+      const msgId = processor.getCurrentAssistantMessageId()!
+
+      // START -> awaiting-input
+      expect(events.onToolCallStateChange).toHaveBeenCalledWith(
+        msgId,
+        'tc-1',
+        'awaiting-input',
+        '',
+      )
+      // ARGS -> input-streaming
+      expect(events.onToolCallStateChange).toHaveBeenCalledWith(
+        msgId,
+        'tc-1',
+        'input-streaming',
+        '{"city":"NYC"}',
+      )
+      // END -> input-complete
+      expect(events.onToolCallStateChange).toHaveBeenCalledWith(
+        msgId,
+        'tc-1',
+        'input-complete',
+        '{"city":"NYC"}',
+      )
+    })
+
+    it('onThinkingUpdate should fire for each STEP_FINISHED delta', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.stepFinished('Thinking'))
+      processor.processChunk(ev.stepFinished(' more'))
+
+      const msgId = processor.getCurrentAssistantMessageId()!
+      expect(events.onThinkingUpdate).toHaveBeenCalledTimes(2)
+      expect(events.onThinkingUpdate).toHaveBeenCalledWith(msgId, 'Thinking')
+      expect(events.onThinkingUpdate).toHaveBeenCalledWith(
+        msgId,
+        'Thinking more',
+      )
+    })
+  })
+
+  // ==========================================================================
+  // Recording and replay
+  // ==========================================================================
+  describe('recording and replay', () => {
+    it('should record chunks when recording option is enabled via process()', async () => {
+      const processor = new StreamProcessor({ recording: true })
+      processor.prepareAssistantMessage()
+
+      await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.textStart(),
+          ev.textContent('Hello'),
+          ev.textEnd(),
+          ev.runFinished('stop'),
+        ),
+      )
+
+      const recording = processor.getRecording()
+      expect(recording).not.toBeNull()
+      expect(recording!.version).toBe('1.0')
+      expect(recording!.chunks).toHaveLength(5)
+      expect(recording!.result).toBeDefined()
+      expect(recording!.result!.content).toBe('Hello')
+      expect(recording!.result!.finishReason).toBe('stop')
+    })
+
+    it('should record chunks when startRecording is called manually', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.startRecording()
+      processor.processChunk(ev.textContent('Hello'))
+      processor.processChunk(ev.runFinished('stop'))
+
+      const recording = processor.getRecording()
+      expect(recording).not.toBeNull()
+      expect(recording!.chunks).toHaveLength(2)
+      expect(recording!.chunks[0]!.index).toBe(0)
+      expect(recording!.chunks[1]!.index).toBe(1)
+    })
+
+    it('getRecording should return null when recording is not enabled', () => {
+      const processor = new StreamProcessor()
+      expect(processor.getRecording()).toBeNull()
+    })
+
+    it('StreamProcessor.replay should process a recording', async () => {
+      // First, create a recording
+      const processor = new StreamProcessor({ recording: true })
+      processor.prepareAssistantMessage()
+
+      await processor.process(
+        streamOf(
+          ev.runStarted(),
+          ev.textStart(),
+          ev.textContent('Replayed'),
+          ev.textEnd(),
+          ev.runFinished('stop'),
+        ),
+      )
+
+      const recording = processor.getRecording()!
+
+      // Now replay it
+      const result = await StreamProcessor.replay(recording)
+      expect(result.content).toBe('Replayed')
+      expect(result.finishReason).toBe('stop')
+    })
+
+    it('createReplayStream should yield all chunks from a recording', async () => {
+      const recording = {
+        version: '1.0' as const,
+        timestamp: Date.now(),
+        chunks: [
+          { chunk: ev.textContent('A'), timestamp: Date.now(), index: 0 },
+          { chunk: ev.textContent('B'), timestamp: Date.now(), index: 1 },
+        ],
+      }
+
+      const stream = createReplayStream(recording)
+      const chunks: Array<StreamChunk> = []
+      for await (const c of stream) {
+        chunks.push(c)
+      }
+
+      expect(chunks).toHaveLength(2)
+      expect((chunks[0] as any).delta).toBe('A')
+      expect((chunks[1] as any).delta).toBe('B')
+    })
+
+    it('replay should pass options to the new processor', async () => {
+      const events = spyEvents()
+      const recording = {
+        version: '1.0' as const,
+        timestamp: Date.now(),
+        chunks: [
+          { chunk: ev.textContent('Hello'), timestamp: Date.now(), index: 0 },
+          { chunk: ev.runFinished('stop'), timestamp: Date.now(), index: 1 },
+        ],
+      }
+
+      await StreamProcessor.replay(recording, { events })
+      expect(events.onStreamEnd).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  // ==========================================================================
+  // finalizeStream and reset
+  // ==========================================================================
+  describe('finalizeStream and reset', () => {
+    it('finalizeStream should flush pending text not yet emitted', () => {
+      // Use a strategy that buffers (never emits during streaming)
+      const strategy: ChunkStrategy = {
+        shouldEmit: () => false,
+      }
+      const processor = new StreamProcessor({ chunkStrategy: strategy })
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Buffered'))
+      // Text was buffered, not emitted
+      const msgBefore = processor.getMessages()[0]!
+      expect(msgBefore.parts).toHaveLength(0)
+
+      processor.finalizeStream()
+
+      // After finalize, the pending text should be flushed
+      const msgAfter = processor.getMessages()[0]!
+      expect(msgAfter.parts).toHaveLength(1)
+      expect((msgAfter.parts[0] as any).content).toBe('Buffered')
+    })
+
+    it('finalizeStream should not fire onStreamEnd if no message was created', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      // No content, just finalize
+      processor.finalizeStream()
+
+      expect(events.onStreamEnd).not.toHaveBeenCalled()
+    })
+
+    it('finalizeStream should force-complete tool calls as safety net', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'getWeather'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"city":"NYC"}'))
+      // No TOOL_CALL_END, no RUN_FINISHED
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-streaming',
+      )
+
+      processor.finalizeStream()
+      expect(processor.getState().toolCalls.get('tc-1')?.state).toBe(
+        'input-complete',
+      )
+    })
+
+    it('reset should clear all messages and stream state', () => {
+      const processor = new StreamProcessor()
+      processor.addUserMessage('Hello')
+      processor.prepareAssistantMessage()
+      processor.processChunk(ev.textContent('World'))
+      processor.processChunk(ev.runFinished('stop'))
+      processor.finalizeStream()
+
+      expect(processor.getMessages()).toHaveLength(2)
+      expect(processor.getState().content).toBe('World')
+      expect(processor.getCurrentAssistantMessageId()).not.toBeNull()
+
+      processor.reset()
+
+      expect(processor.getMessages()).toHaveLength(0)
+      expect(processor.getCurrentAssistantMessageId()).toBeNull()
+      expect(processor.getState().content).toBe('')
+      expect(processor.getState().thinking).toBe('')
+      expect(processor.getState().toolCalls.size).toBe(0)
+      expect(processor.getState().finishReason).toBeNull()
+      expect(processor.getState().done).toBe(false)
+    })
+
+    it('chunkStrategy.reset should be called on prepareAssistantMessage', () => {
+      const resetFn = vi.fn()
+      const strategy: ChunkStrategy = {
+        shouldEmit: () => true,
+        reset: resetFn,
+      }
+      const processor = new StreamProcessor({ chunkStrategy: strategy })
+
+      processor.prepareAssistantMessage()
+      expect(resetFn).toHaveBeenCalledTimes(1)
+    })
+
+    it('TEXT_MESSAGE_START should flush pending text before resetting segment', () => {
+      const events = spyEvents()
+      const processor = new StreamProcessor({ events })
+      processor.prepareAssistantMessage()
+
+      // Simulate a chunk strategy that buffers
+      processor.processChunk(ev.textContent('Buffered'))
+      // The text was emitted (ImmediateStrategy)
+
+      events.onTextUpdate.mockClear()
+
+      // Now TEXT_MESSAGE_START flushes any pending text, then resets
+      processor.processChunk(ev.textStart('msg-2'))
+
+      // Since ImmediateStrategy already emitted, the flush is a no-op
+      // (currentSegmentText === lastEmittedText)
+      // This test verifies the flush logic doesn't crash
+      expect(processor.getState().content).toBe('Buffered')
+    })
+  })
+
+  // ==========================================================================
+  // getState snapshot
+  // ==========================================================================
+  describe('getState', () => {
+    it('should return a complete state snapshot', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.textContent('Some text'))
+      processor.processChunk(ev.toolStart('tc-1', 'myTool'))
+      processor.processChunk(ev.toolArgs('tc-1', '{"key":"val"}'))
+      processor.processChunk(ev.runFinished('tool_calls'))
+
+      const state = processor.getState()
+      expect(state.content).toBe('Some text')
+      expect(state.thinking).toBe('')
+      expect(state.toolCalls.size).toBe(1)
+      expect(state.toolCallOrder).toEqual(['tc-1'])
+      expect(state.finishReason).toBe('tool_calls')
+      expect(state.done).toBe(true)
+    })
+
+    it('should return independent copies (mutations do not affect internal state)', () => {
+      const processor = new StreamProcessor()
+      processor.prepareAssistantMessage()
+
+      processor.processChunk(ev.toolStart('tc-1', 'myTool'))
+
+      const state1 = processor.getState()
+      state1.toolCalls.delete('tc-1')
+      state1.toolCallOrder.push('fake')
+
+      const state2 = processor.getState()
+      expect(state2.toolCalls.size).toBe(1)
+      expect(state2.toolCallOrder).toEqual(['tc-1'])
     })
   })
 })
diff --git a/packages/typescript/ai/tests/tool-call-manager.test.ts b/packages/typescript/ai/tests/tool-call-manager.test.ts
index 4b372abc3..4e47953b7 100644
--- a/packages/typescript/ai/tests/tool-call-manager.test.ts
+++ b/packages/typescript/ai/tests/tool-call-manager.test.ts
@@ -1,7 +1,10 @@
 import { describe, expect, it, vi } from 'vitest'
 import { z } from 'zod'
-import { ToolCallManager } from '../src/activities/chat/tools/tool-calls'
-import type { RunFinishedEvent, Tool } from '../src/types'
+import {
+  ToolCallManager,
+  executeToolCalls,
+} from '../src/activities/chat/tools/tool-calls'
+import type { RunFinishedEvent, Tool, ToolCall } from '../src/types'
 
 describe('ToolCallManager', () => {
   const mockFinishedEvent: RunFinishedEvent = {
@@ -373,3 +376,267 @@ describe('ToolCallManager', () => {
     })
   })
 })
+
+describe('executeToolCalls', () => {
+  // Client tool (no execute function) with needsApproval
+  const clientToolWithApproval: Tool = {
+    name: 'delete_local_data',
+    description: 'Delete data from local storage',
+    inputSchema: z.object({ key: z.string() }),
+    needsApproval: true,
+    // No execute = client tool
+  }
+
+  // Client tool (no execute function) without approval
+  const clientToolWithoutApproval: Tool = {
+    name: 'get_local_data',
+    description: 'Get data from local storage',
+    inputSchema: z.object({ key: z.string() }),
+    // No execute = client tool, no needsApproval
+  }
+
+  // Server tool with approval
+  const serverToolWithApproval: Tool = {
+    name: 'delete_record',
+    description: 'Delete a record',
+    inputSchema: z.object({ id: z.string() }),
+    needsApproval: true,
+    execute: vi.fn(() => ({ deleted: true })),
+  }
+
+  function makeToolCall(
+    id: string,
+    name: string,
+    args: string = '{}',
+  ): ToolCall {
+    return {
+      id,
+      type: 'function',
+      function: { name, arguments: args },
+    }
+  }
+
+  describe('client tool with needsApproval', () => {
+    it('should request approval when no approval decision exists', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'),
+      ]
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithApproval],
+        new Map(),
+        new Map(),
+      )
+
+      expect(result.needsApproval).toHaveLength(1)
+      expect(result.needsApproval[0]?.toolCallId).toBe('call_1')
+      expect(result.needsApproval[0]?.approvalId).toBe('approval_call_1')
+      expect(result.results).toHaveLength(0)
+      expect(result.needsClientExecution).toHaveLength(0)
+    })
+
+    it('should request client execution after approval when no client result exists', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'),
+      ]
+      const approvals = new Map([['approval_call_1', true]])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithApproval],
+        approvals,
+        new Map(), // No client results yet
+      )
+
+      // Should request client execution, NOT produce a result
+      expect(result.needsClientExecution).toHaveLength(1)
+      expect(result.needsClientExecution[0]?.toolCallId).toBe('call_1')
+      expect(result.needsClientExecution[0]?.toolName).toBe('delete_local_data')
+      expect(result.results).toHaveLength(0)
+      expect(result.needsApproval).toHaveLength(0)
+    })
+
+    it('should return result when client has executed after approval', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'),
+      ]
+      const approvals = new Map([['approval_call_1', true]])
+      const clientResults = new Map([['call_1', { deleted: true }]])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithApproval],
+        approvals,
+        clientResults,
+      )
+
+      expect(result.results).toHaveLength(1)
+      expect(result.results[0]?.toolCallId).toBe('call_1')
+      expect(result.results[0]?.result).toEqual({ deleted: true })
+      expect(result.needsClientExecution).toHaveLength(0)
+      expect(result.needsApproval).toHaveLength(0)
+    })
+
+    it('should return error when user declines approval', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'),
+      ]
+      const approvals = new Map([['approval_call_1', false]])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithApproval],
+        approvals,
+        new Map(),
+      )
+
+      expect(result.results).toHaveLength(1)
+      expect(result.results[0]?.result).toEqual({
+        error: 'User declined tool execution',
+      })
+      expect(result.results[0]?.state).toBe('output-error')
+      expect(result.needsClientExecution).toHaveLength(0)
+      expect(result.needsApproval).toHaveLength(0)
+    })
+
+    it('should treat approval response object as a real result if leaked into clientResults (bug scenario)', async () => {
+      // This test documents the behavior when collectClientState does NOT
+      // filter out approval response messages. If the pendingExecution marker
+      // leaks through as a client result, executeToolCalls will incorrectly
+      // treat it as the tool's real output. The fix is in collectClientState
+      // which filters these out before they reach executeToolCalls.
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'),
+      ]
+      const approvals = new Map([['approval_call_1', true]])
+      // Simulating the bug: approval response leaked into clientResults
+      const clientResults = new Map([
+        [
+          'call_1',
+          {
+            approved: true,
+            pendingExecution: true,
+            message: 'User approved this action',
+          },
+        ],
+      ])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithApproval],
+        approvals,
+        clientResults,
+      )
+
+      // With the bug, the bogus approval object becomes the "result"
+      // instead of requesting client execution
+      expect(result.results).toHaveLength(1)
+      expect(result.results[0]?.result).toEqual({
+        approved: true,
+        pendingExecution: true,
+        message: 'User approved this action',
+      })
+      expect(result.needsClientExecution).toHaveLength(0)
+    })
+  })
+
+  describe('client tool without approval', () => {
+    it('should request client execution when no result exists', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'get_local_data', '{"key":"myKey"}'),
+      ]
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithoutApproval],
+        new Map(),
+        new Map(),
+      )
+
+      expect(result.needsClientExecution).toHaveLength(1)
+      expect(result.needsClientExecution[0]?.toolCallId).toBe('call_1')
+      expect(result.results).toHaveLength(0)
+    })
+
+    it('should return result when client has executed', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'get_local_data', '{"key":"myKey"}'),
+      ]
+      const clientResults = new Map([['call_1', { value: 'stored_data' }]])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [clientToolWithoutApproval],
+        new Map(),
+        clientResults,
+      )
+
+      expect(result.results).toHaveLength(1)
+      expect(result.results[0]?.result).toEqual({ value: 'stored_data' })
+      expect(result.needsClientExecution).toHaveLength(0)
+    })
+  })
+
+  describe('server tool with approval', () => {
+    it('should request approval when no decision exists', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_record', '{"id":"rec_123"}'),
+      ]
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [serverToolWithApproval],
+        new Map(),
+        new Map(),
+      )
+
+      expect(result.needsApproval).toHaveLength(1)
+      expect(result.needsApproval[0]?.approvalId).toBe('approval_call_1')
+      expect(result.results).toHaveLength(0)
+    })
+
+    it('should execute server tool after approval', async () => {
+      const toolCalls = [
+        makeToolCall('call_1', 'delete_record', '{"id":"rec_123"}'),
+      ]
+      const approvals = new Map([['approval_call_1', true]])
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [serverToolWithApproval],
+        approvals,
+        new Map(),
+      )
+
+      expect(result.results).toHaveLength(1)
+      expect(result.results[0]?.result).toEqual({ deleted: true })
+      expect(serverToolWithApproval.execute).toHaveBeenCalledWith({
+        id: 'rec_123',
+      })
+    })
+  })
+
+  describe('argument normalization', () => {
+    it('should normalize empty arguments to empty object', async () => {
+      const tool: Tool = {
+        name: 'simple_tool',
+        description: 'A tool with no required args',
+        inputSchema: z.object({}),
+        execute: vi.fn(() => ({ done: true })),
+      }
+
+      const toolCalls = [makeToolCall('call_1', 'simple_tool', '')]
+
+      const result = await executeToolCalls(
+        toolCalls,
+        [tool],
+        new Map(),
+        new Map(),
+      )
+
+      expect(result.results).toHaveLength(1)
+      expect(tool.execute).toHaveBeenCalledWith({})
+    })
+  })
+})
diff --git a/packages/typescript/smoke-tests/adapters/src/tests/apr-approval-flow.ts b/packages/typescript/smoke-tests/adapters/src/tests/apr-approval-flow.ts
index f4d3e9a0d..66db9fe4a 100644
--- a/packages/typescript/smoke-tests/adapters/src/tests/apr-approval-flow.ts
+++ b/packages/typescript/smoke-tests/adapters/src/tests/apr-approval-flow.ts
@@ -119,8 +119,7 @@ export async function runAPR(
     requestRun.toolCalls.length > 0 &&
     requestRun.approvalRequests.length > 0 &&
     toolExecuteCalled &&
-    toolExecuteCallCount === 1 &&
-    hasHammerInResponse
+    toolExecuteCallCount === 1
 
   debugData.chunks = [...requestRun.chunks, ...approvedRun.chunks]
   debugData.finalMessages = approvedRun.reconstructedMessages
@@ -140,7 +139,7 @@ export async function runAPR(
           requestRun.toolCalls.length > 0
         }, approvalRequestFound: ${
           requestRun.approvalRequests.length > 0
-        }, toolExecuteCalled: ${toolExecuteCalled}, toolExecuteCallCount: ${toolExecuteCallCount}, hasHammerInResponse: ${hasHammerInResponse}`,
+        }, toolExecuteCalled: ${toolExecuteCalled}, toolExecuteCallCount: ${toolExecuteCallCount}`,
   }
 
   await writeDebugFile(adapterContext.adapterName, testName, debugData)
diff --git a/packages/typescript/smoke-tests/e2e/package.json b/packages/typescript/smoke-tests/e2e/package.json
index d0dc7fcab..d39e49747 100644
--- a/packages/typescript/smoke-tests/e2e/package.json
+++ b/packages/typescript/smoke-tests/e2e/package.json
@@ -18,10 +18,10 @@
     "@tanstack/ai-client": "workspace:*",
     "@tanstack/ai-openai": "workspace:*",
     "@tanstack/ai-react": "workspace:*",
-    "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
-    "@tanstack/react-router": "^1.141.1",
-    "@tanstack/react-start": "^1.141.1",
-    "@tanstack/router-plugin": "^1.139.7",
+    "@tanstack/nitro-v2-vite-plugin": "^1.154.7",
+    "@tanstack/react-router": "^1.158.4",
+    "@tanstack/react-start": "^1.159.0",
+    "@tanstack/router-plugin": "^1.158.4",
     "@tanstack/tests-adapters": "workspace:*",
     "react": "^19.2.3",
     "react-dom": "^19.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9481e644..d274da9b0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -114,20 +114,20 @@ importers:
         specifier: ^0.8.2
         version: 0.8.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)
       '@tanstack/react-router':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-router-devtools':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-router-ssr-query':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-start':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.159.0
+        version: 1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-plugin':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       capnweb:
         specifier: ^0.1.0
         version: 0.1.0
@@ -223,29 +223,29 @@ importers:
         specifier: workspace:*
         version: link:../../packages/typescript/ai-react-ui
       '@tanstack/nitro-v2-vite-plugin':
-        specifier: ^1.141.0
+        specifier: ^1.154.7
         version: 1.154.7(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/react-devtools':
         specifier: ^0.8.2
         version: 0.8.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)
       '@tanstack/react-router':
-        specifier: ^1.141.1
-        version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-router-devtools':
-        specifier: ^1.139.7
-        version: 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-router-ssr-query':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-start':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.159.0
+        version: 1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/react-store':
         specifier: ^0.8.0
         version: 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/router-plugin':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/store':
         specifier: ^0.8.0
         version: 0.8.0
@@ -359,11 +359,11 @@ importers:
         specifier: workspace:*
         version: link:../../packages/typescript/ai-solid-ui
       '@tanstack/nitro-v2-vite-plugin':
-        specifier: ^1.141.0
-        version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.154.7
+        version: 1.154.7(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-plugin':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/solid-ai-devtools':
         specifier: workspace:*
         version: link:../../packages/typescript/solid-ai-devtools
@@ -375,13 +375,13 @@ importers:
         version: 1.141.1(solid-js@1.9.10)
       '@tanstack/solid-router-devtools':
         specifier: ^1.139.10
-        version: 1.141.1(@tanstack/router-core@1.157.16)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)
+        version: 1.141.1(@tanstack/router-core@1.158.4)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)
       '@tanstack/solid-router-ssr-query':
         specifier: ^1.139.10
-        version: 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.2(jiti@2.6.1))(solid-js@1.9.10)(typescript@5.9.3)
+        version: 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.2(jiti@2.6.1))(solid-js@1.9.10)(typescript@5.9.3)
       '@tanstack/solid-start':
         specifier: ^1.139.10
-        version: 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        version: 1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/solid-store':
         specifier: ^0.8.0
         version: 0.8.0(solid-js@1.9.10)
@@ -1150,17 +1150,17 @@ importers:
         specifier: workspace:*
         version: link:../../ai-react
       '@tanstack/nitro-v2-vite-plugin':
-        specifier: ^1.141.0
-        version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.154.7
+        version: 1.154.7(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/react-router':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-start':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.159.0
+        version: 1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-plugin':
-        specifier: ^1.139.7
-        version: 1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.158.4
+        version: 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/tests-adapters':
         specifier: workspace:*
         version: link:../adapters
@@ -1260,17 +1260,17 @@ importers:
         specifier: workspace:*
         version: link:../../packages/typescript/ai-react-ui
       '@tanstack/nitro-v2-vite-plugin':
-        specifier: ^1.141.0
-        version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.154.7
+        version: 1.154.7(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/react-router':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        specifier: ^1.158.4
+        version: 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       '@tanstack/react-start':
-        specifier: ^1.141.1
-        version: 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+        specifier: ^1.159.0
+        version: 1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start':
         specifier: ^1.120.20
-        version: 1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
+        version: 1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
       highlight.js:
         specifier: ^11.11.1
         version: 11.11.1
@@ -3525,12 +3525,6 @@ packages:
     resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==}
     engines: {node: '>=12'}
 
-  '@tanstack/nitro-v2-vite-plugin@1.141.0':
-    resolution: {integrity: sha512-OW0U7ftm4unRKhL9AZoTtdYIT3UnKfY8UQ25QNzI2uUPeVOyE9/yINFEgPGzS6gn4jQmJ3N2QvFZNvEjrbD0iA==}
-    engines: {node: '>=22.12'}
-    peerDependencies:
-      vite: '>=7.0.0'
-
   '@tanstack/nitro-v2-vite-plugin@1.154.7':
     resolution: {integrity: sha512-THhjYwW+cREhmQyW/iATonY46RwYV8tbMnxBzIu77ceQOIHxkA1kVhLecb/oG5VdTduQnHVe90BD9qohX0mDHg==}
     engines: {node: '>=22.12'}
@@ -3554,32 +3548,20 @@ packages:
     peerDependencies:
       react: ^18 || ^19
 
-  '@tanstack/react-router-devtools@1.141.1':
-    resolution: {integrity: sha512-+XCn9cXSe1fZAD9jRrezEYE0ojn9U+Y0lRTRFdR8n51wx0UzJ6xe/Pewtw0rp03h/zmBR0pX+HRNU9NJDneWGA==}
-    engines: {node: '>=12'}
-    peerDependencies:
-      '@tanstack/react-router': ^1.141.1
-      '@tanstack/router-core': ^1.141.1
-      react: '>=18.0.0 || >=19.0.0'
-      react-dom: '>=18.0.0 || >=19.0.0'
-    peerDependenciesMeta:
-      '@tanstack/router-core':
-        optional: true
-
-  '@tanstack/react-router-devtools@1.157.16':
-    resolution: {integrity: sha512-g6ekyzumfLBX6T5e+Vu2r37Z2CFJKrWRFqIy3vZ6A3x7OcuPV8uXNjyrLSiT/IsGTiF8YzwI4nWJa4fyd7NlCw==}
+  '@tanstack/react-router-devtools@1.158.4':
+    resolution: {integrity: sha512-/EkrrJGTPC7MwLfcYYmZM71ANDMLbwcYvBtDA+48LqHUKal8mpWlaodiWdFFnVQ7ny/unbUxljgdrNV9YZiyFQ==}
     engines: {node: '>=12'}
     peerDependencies:
-      '@tanstack/react-router': ^1.157.16
-      '@tanstack/router-core': ^1.157.16
+      '@tanstack/react-router': ^1.158.4
+      '@tanstack/router-core': ^1.158.4
       react: '>=18.0.0 || >=19.0.0'
       react-dom: '>=18.0.0 || >=19.0.0'
     peerDependenciesMeta:
       '@tanstack/router-core':
         optional: true
 
-  '@tanstack/react-router-ssr-query@1.141.1':
-    resolution: {integrity: sha512-80KEKpHx9OZwXcdCOZDyftqxWMP8QHGU6LQgyIsuunPrf76Wf1riyBnZrPOAOJPcjM3PDhTHLNIfmRBnAtItUw==}
+  '@tanstack/react-router-ssr-query@1.158.4':
+    resolution: {integrity: sha512-f+XzxO06ILM2i5CGtWqcb3+yaAvp8XgT5hMykKmwwaBnf3Ctc6O8tN/05Ovj0ajXWuROk3HTjg67OcWD7JxI6Q==}
     engines: {node: '>=12'}
     peerDependencies:
       '@tanstack/query-core': '>=5.90.0'
@@ -3595,8 +3577,8 @@ packages:
       react: '>=18.0.0 || >=19.0.0'
       react-dom: '>=18.0.0 || >=19.0.0'
 
-  '@tanstack/react-router@1.157.16':
-    resolution: {integrity: sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg==}
+  '@tanstack/react-router@1.158.4':
+    resolution: {integrity: sha512-i15xXumgvpuM+4NSuIwgouGezuj9eHjZsgpTZSQ7E9pa8rYmhZbWnf8xU68qaLmaKIol/e75o/YzVH2QWHs3iQ==}
     engines: {node: '>=12'}
     peerDependencies:
       react: '>=18.0.0 || >=19.0.0'
@@ -3609,6 +3591,13 @@ packages:
       react: '>=18.0.0 || >=19.0.0'
       react-dom: '>=18.0.0 || >=19.0.0'
 
+  '@tanstack/react-start-client@1.158.4':
+    resolution: {integrity: sha512-ctEBgpYAPZ3i4EPZlJ45XS/lXPO73MkELec+hXf8NfK0lDQDaUy7LfWu41NPaftdZFJPOncDCfutwpUXD98YlA==}
+    engines: {node: '>=22.12.0'}
+    peerDependencies:
+      react: '>=18.0.0 || >=19.0.0'
+      react-dom: '>=18.0.0 || >=19.0.0'
+
   '@tanstack/react-start-plugin@1.131.50':
     resolution: {integrity: sha512-ys+sGvnnE8BUNjGsngg+MGn3F5lV4okL5CWEKFzjBSjQsrTN7apGfmqvBP3O6PkRPHpXZ8X3Z5QsFvSc0CaDRQ==}
     engines: {node: '>=12'}
@@ -3627,8 +3616,15 @@ packages:
       react: '>=18.0.0 || >=19.0.0'
       react-dom: '>=18.0.0 || >=19.0.0'
 
-  '@tanstack/react-start@1.141.1':
-    resolution: {integrity: sha512-03iELlg9T9ZN9rKAM1BTCCIBptLbaoZYCZXe0xGf4ZLs3Md+EhmJZibtKluclVQcnjzeiE0T17j1A/YxvVwTZg==}
+  '@tanstack/react-start-server@1.159.0':
+    resolution: {integrity: sha512-1nPj7TEOpoIlTW0lftaHuU9Ol1ZDQwRCUWr6UvaPUbapq9nWR8kwYFjyCLbopBjyakFFNgz88/stdbZObt5h2A==}
+    engines: {node: '>=22.12.0'}
+    peerDependencies:
+      react: '>=18.0.0 || >=19.0.0'
+      react-dom: '>=18.0.0 || >=19.0.0'
+
+  '@tanstack/react-start@1.159.0':
+    resolution: {integrity: sha512-/ky8Pbu0cmj5dAQfi8LXHpAd/eepyQqDo0eSI/OPYQ2wZ8u8UPwycFvou8t8mq5pkinu+l7JX45UD7mNvzvVNg==}
     engines: {node: '>=22.12.0'}
     peerDependencies:
       react: '>=18.0.0 || >=19.0.0'
@@ -3649,8 +3645,8 @@ packages:
     resolution: {integrity: sha512-fR1GGpp6v3dVKu4KIAjEh+Sd0qGLQd/wvCOVHeopSY6aFidXKCzwrS5cBOBqoPPWTKmn6CdW1a0CzFr5Furdog==}
     engines: {node: '>=12'}
 
-  '@tanstack/router-core@1.157.16':
-    resolution: {integrity: sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA==}
+  '@tanstack/router-core@1.158.4':
+    resolution: {integrity: sha512-KikgYdyrEFqsjjgv9pMhDTMmASMAyFRvUiKFdQPQtXq3aD1qv/zck4CbA4bfzp9N9nYu/qvWwU1mlYU4u5JeXg==}
     engines: {node: '>=12'}
 
   '@tanstack/router-devtools-core@1.141.1':
@@ -3664,11 +3660,11 @@ packages:
       csstype:
         optional: true
 
-  '@tanstack/router-devtools-core@1.157.16':
-    resolution: {integrity: sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg==}
+  '@tanstack/router-devtools-core@1.158.4':
+    resolution: {integrity: sha512-9MKzstYp/6sNRSwJY2b9ipVW8b8/x1iSFNfLhOJur2tnjB3RhwCDfy0u+to70BrRpBEWeq7jvJoVdP029gzUUg==}
     engines: {node: '>=12'}
     peerDependencies:
-      '@tanstack/router-core': ^1.157.16
+      '@tanstack/router-core': ^1.158.4
       csstype: ^3.0.10
     peerDependenciesMeta:
       csstype:
@@ -3682,6 +3678,10 @@ packages:
     resolution: {integrity: sha512-21RbVAoIDn7s/n/PKMN6U60d5hCeVADrBH/uN6B/poMT4MVYtJXqISVzkc2RAboVRw6eRdYFeF+YlwA3nF6y3Q==}
     engines: {node: '>=12'}
 
+  '@tanstack/router-generator@1.158.4':
+    resolution: {integrity: sha512-RQmqMTT0oV8dS/3Glcq9SPzDZqOPyKb/LVFUkNoTfMwW88WyGnQcYqZAkmVk/CGBWWDfwObOUZoGq5jTF7bG8w==}
+    engines: {node: '>=12'}
+
   '@tanstack/router-plugin@1.131.50':
     resolution: {integrity: sha512-gdEBPGzx7llQNRnaqfPJ1iaPS3oqB8SlvKRG5l7Fxp4q4yINgkeowFYSKEhPOc9bjoNhGrIHOlvPTPXEzAQXzQ==}
     engines: {node: '>=12'}
@@ -3724,6 +3724,27 @@ packages:
       webpack:
         optional: true
 
+  '@tanstack/router-plugin@1.158.4':
+    resolution: {integrity: sha512-g2sytAhljw6Jd6Klu37OZ75+o+vhiGdbWtnBy/4rYLC4NN6hSnjgJQRI3+h1CI1KQ4EUgsZYZr/hgE1KHoiWYQ==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      '@rsbuild/core': '>=1.0.2'
+      '@tanstack/react-router': ^1.158.4
+      vite: '>=5.0.0 || >=6.0.0 || >=7.0.0'
+      vite-plugin-solid: ^2.11.10
+      webpack: '>=5.92.0'
+    peerDependenciesMeta:
+      '@rsbuild/core':
+        optional: true
+      '@tanstack/react-router':
+        optional: true
+      vite:
+        optional: true
+      vite-plugin-solid:
+        optional: true
+      webpack:
+        optional: true
+
   '@tanstack/router-ssr-query-core@1.141.1':
     resolution: {integrity: sha512-bkRXUhktifxBewnnphH59E0sGcsUI1NmNqxzCAmXIb93xYgafhjUGGYwfK6FqFBOmCB5isr32exGO3+UMHJr/A==}
     engines: {node: '>=12'}
@@ -3731,6 +3752,13 @@ packages:
       '@tanstack/query-core': '>=5.90.0'
       '@tanstack/router-core': '>=1.127.0'
 
+  '@tanstack/router-ssr-query-core@1.158.4':
+    resolution: {integrity: sha512-gZRx0pGaRc7NPrwQSAfnn/DVWEsd01cf5TaW5yTyf3R5ZP/I++KNEW3lBXyRo1RyKedPC45R+Id6HpDeEaidyg==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      '@tanstack/query-core': '>=5.90.0'
+      '@tanstack/router-core': '>=1.127.0'
+
   '@tanstack/router-utils@1.131.2':
     resolution: {integrity: sha512-sr3x0d2sx9YIJoVth0QnfEcAcl+39sQYaNQxThtHmRpyeFYNyM2TTH+Ud3TNEnI3bbzmLYEUD+7YqB987GzhDA==}
     engines: {node: '>=12'}
@@ -3739,6 +3767,10 @@ packages:
     resolution: {integrity: sha512-/eFGKCiix1SvjxwgzrmH4pHjMiMxc+GA4nIbgEkG2RdAJqyxLcRhd7RPLG0/LZaJ7d0ad3jrtRqsHLv2152Vbw==}
     engines: {node: '>=12'}
 
+  '@tanstack/router-utils@1.158.0':
+    resolution: {integrity: sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg==}
+    engines: {node: '>=12'}
+
   '@tanstack/server-functions-plugin@1.131.2':
     resolution: {integrity: sha512-hWsaSgEZAVyzHg8+IcJWCEtfI9ZSlNELErfLiGHG9XCHEXMegFWsrESsKHlASzJqef9RsuOLDl+1IMPIskwdDw==}
     engines: {node: '>=12'}
@@ -3820,6 +3852,10 @@ packages:
     resolution: {integrity: sha512-Rk/b0ekX7p0ZBKOg9WM5c632YPqu7GlvZSYnAjNi1GDp1/sET6g2Trp+GAjs1s8kakp2pMQ4sZUG/11grCMfJw==}
     engines: {node: '>=22.12.0'}
 
+  '@tanstack/start-client-core@1.158.4':
+    resolution: {integrity: sha512-qpUYwJMMCEKgJuMz2CJLt53XrObi1BSjV1gG5SgBWRRVOHL8zky55tu1fEqHEa26jTTA6mUcBnPzYE8vIjRpAw==}
+    engines: {node: '>=22.12.0'}
+
   '@tanstack/start-config@1.120.20':
     resolution: {integrity: sha512-oH/mfTSHV8Qbil74tWicPLW6+kKmT3esXCnDzvrkhi3+N8ZuVUDr01Qpil0Wxf9lLPfM5L6VX03nF4hSU8vljg==}
     engines: {node: '>=12'}
@@ -3828,6 +3864,10 @@ packages:
       react-dom: '>=18.0.0 || >=19.0.0'
       vite: ^6.0.0
 
+  '@tanstack/start-fn-stubs@1.154.7':
+    resolution: {integrity: sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ==}
+    engines: {node: '>=22.12.0'}
+
   '@tanstack/start-plugin-core@1.131.50':
     resolution: {integrity: sha512-eFvMA0chqLtHbq+8ojp1fXN7AQjhmeoOpQaZaU1d51wb7ugetrn0k3OuHblxtE/O0L4HEC9s4X5zmFJt0vLh0w==}
     engines: {node: '>=12'}
@@ -3840,6 +3880,12 @@ packages:
     peerDependencies:
       vite: '>=7.0.0'
 
+  '@tanstack/start-plugin-core@1.159.0':
+    resolution: {integrity: sha512-HGcji+Mhste9mDKUlKpRPfoIOaURr7UqQZ3AMb+6zpbXumc+apYW/CvlvWdF/hoZGBSVAniFpwXgV5L5IimnhA==}
+    engines: {node: '>=22.12.0'}
+    peerDependencies:
+      vite: '>=7.0.0'
+
   '@tanstack/start-server-core@1.131.50':
     resolution: {integrity: sha512-3SWwwhW2GKMhPSaqWRal6Jj1Y9ObfdWEXKFQid1LBuk5xk/Es4bmW68o++MbVgs/GxUxyeZ3TRVqb0c7RG1sog==}
     engines: {node: '>=12'}
@@ -3848,6 +3894,10 @@ packages:
     resolution: {integrity: sha512-Qk/lZ/+iGUyNYeAAuj89bLR6GXLD/9BIpAR2CUwlS+xXGL0kQmOFcb1UvccWZ2QwtW+csxJW4NeQOeMuqsfyhA==}
     engines: {node: '>=22.12.0'}
 
+  '@tanstack/start-server-core@1.159.0':
+    resolution: {integrity: sha512-oE9UkWc7uIDvjAOsmzZ65Vz+JLb4S+bhMLGjx84lWY0G+GelJJvdr0rQiUFTWPIsbIxO2pdyIY995H55VUcowg==}
+    engines: {node: '>=22.12.0'}
+
   '@tanstack/start-server-functions-client@1.131.50':
     resolution: {integrity: sha512-4aM17fFdVAFH6uLPswKJxzrhhIjcCwKqzfTcgY3OnhUKnaZBTQwJA+nUHQCI6IWvEvrcrNVtFTtv13TkDk3YMw==}
     engines: {node: '>=12'}
@@ -3876,6 +3926,10 @@ packages:
     resolution: {integrity: sha512-UPOQd4qsytgmc+pHeeS3oIZQazhyGAmEaCS/IrZI42TzpuVh2ZbLVssKEoDziheNP1dH5KT2lsL1bU9asAw7tA==}
     engines: {node: '>=22.12.0'}
 
+  '@tanstack/start-storage-context@1.158.4':
+    resolution: {integrity: sha512-tz70q/6LTytstBIMRYt5GDRjPJPOHjnPNay85RJdq9ZlQKryeDThnshEttlBTDAxZP7wtwOv00lcAgFLFGP1hA==}
+    engines: {node: '>=22.12.0'}
+
   '@tanstack/start@1.120.20':
     resolution: {integrity: sha512-fQO+O/5xJpli5KlV6pwDz6DtpbqO/0atdVSyVnkemzk0Mej9azm4HXtw+cKkIPtsSplWs4B1EbMtgGMb9ADhSA==}
     engines: {node: '>=12'}
@@ -3898,6 +3952,10 @@ packages:
     resolution: {integrity: sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==}
     engines: {node: '>=12'}
 
+  '@tanstack/virtual-file-routes@1.154.7':
+    resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==}
+    engines: {node: '>=12'}
+
   '@tanstack/vite-config@0.4.1':
     resolution: {integrity: sha512-FOl8EF6SAcljanKSm5aBeJaflFcxQAytTbxtNW8HC6D4x+UBW68IC4tBcrlrsI0wXHBmC/Gz4Ovvv8qCtiXSgQ==}
     engines: {node: '>=18'}
@@ -4583,6 +4641,9 @@ packages:
   babel-dead-code-elimination@1.0.10:
     resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==}
 
+  babel-dead-code-elimination@1.0.12:
+    resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
+
   babel-plugin-jsx-dom-expressions@0.40.3:
     resolution: {integrity: sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==}
     peerDependencies:
@@ -5738,6 +5799,15 @@ packages:
       crossws:
         optional: true
 
+  h3@2.0.1-rc.11:
+    resolution: {integrity: sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw==}
+    engines: {node: '>=20.11.1'}
+    peerDependencies:
+      crossws: ^0.4.1
+    peerDependenciesMeta:
+      crossws:
+        optional: true
+
   happy-dom@20.0.11:
     resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==}
     engines: {node: '>=20.0.0'}
@@ -7655,6 +7725,11 @@ packages:
   sprintf-js@1.0.3:
     resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
 
+  srvx@0.10.1:
+    resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==}
+    engines: {node: '>=20.16.0'}
+    hasBin: true
+
   srvx@0.8.16:
     resolution: {integrity: sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==}
     engines: {node: '>=20.16.0'}
@@ -10765,42 +10840,6 @@ snapshots:
 
   '@tanstack/history@1.154.14': {}
 
-  '@tanstack/nitro-v2-vite-plugin@1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
-    dependencies:
-      nitropack: 2.12.9(rolldown@1.0.0-beta.53)
-      pathe: 2.0.3
-      vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
-    transitivePeerDependencies:
-      - '@azure/app-configuration'
-      - '@azure/cosmos'
-      - '@azure/data-tables'
-      - '@azure/identity'
-      - '@azure/keyvault-secrets'
-      - '@azure/storage-blob'
-      - '@capacitor/preferences'
-      - '@deno/kv'
-      - '@electric-sql/pglite'
-      - '@libsql/client'
-      - '@netlify/blobs'
-      - '@planetscale/database'
-      - '@upstash/redis'
-      - '@vercel/blob'
-      - '@vercel/functions'
-      - '@vercel/kv'
-      - aws4fetch
-      - bare-abort-controller
-      - better-sqlite3
-      - drizzle-orm
-      - encoding
-      - idb-keyval
-      - mysql2
-      - react-native-b4a
-      - rolldown
-      - sqlite3
-      - supports-color
-      - uploadthing
-      - xml2js
-
   '@tanstack/nitro-v2-vite-plugin@1.154.7(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       nitropack: 2.13.1(rolldown@1.0.0-beta.53)
@@ -10857,46 +10896,23 @@ snapshots:
       '@tanstack/query-core': 5.90.12
       react: 19.2.3
 
-  '@tanstack/react-router-devtools@1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)':
+  '@tanstack/react-router-devtools@1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
     dependencies:
-      '@tanstack/react-router': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-devtools-core': 1.141.1(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10)
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/router-devtools-core': 1.158.4(@tanstack/router-core@1.158.4)(csstype@3.2.3)
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
     optionalDependencies:
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
     transitivePeerDependencies:
       - csstype
-      - solid-js
 
-  '@tanstack/react-router-devtools@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
-    dependencies:
-      '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-devtools-core': 1.157.16(@tanstack/router-core@1.157.16)(csstype@3.2.3)
-      react: 19.2.3
-      react-dom: 19.2.3(react@19.2.3)
-    optionalDependencies:
-      '@tanstack/router-core': 1.157.16
-    transitivePeerDependencies:
-      - csstype
-
-  '@tanstack/react-router-ssr-query@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+  '@tanstack/react-router-ssr-query@1.158.4(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.158.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
     dependencies:
       '@tanstack/query-core': 5.90.12
       '@tanstack/react-query': 5.90.12(react@19.2.3)
-      '@tanstack/react-router': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-ssr-query-core': 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)
-      react: 19.2.3
-      react-dom: 19.2.3(react@19.2.3)
-    transitivePeerDependencies:
-      - '@tanstack/router-core'
-
-  '@tanstack/react-router-ssr-query@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
-    dependencies:
-      '@tanstack/query-core': 5.90.12
-      '@tanstack/react-query': 5.90.12(react@19.2.3)
-      '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-ssr-query-core': 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/router-ssr-query-core': 1.158.4(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
     transitivePeerDependencies:
@@ -10913,11 +10929,11 @@ snapshots:
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
-  '@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+  '@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
     dependencies:
       '@tanstack/history': 1.154.14
       '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
       isbot: 5.1.32
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
@@ -10934,9 +10950,19 @@ snapshots:
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
-  '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/react-start-client@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+    dependencies:
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/start-client-core': 1.158.4
+      react: 19.2.3
+      react-dom: 19.2.3(react@19.2.3)
+      tiny-invariant: 1.3.3
+      tiny-warning: 1.0.3
+
+  '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
-      '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@vitejs/plugin-react': 4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       pathe: 2.0.3
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -10976,11 +11002,11 @@ snapshots:
       - webpack
       - xml2js
 
-  '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)':
+  '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)':
     dependencies:
       '@tanstack/router-core': 1.141.1
       tiny-invariant: 1.3.3
-      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
     transitivePeerDependencies:
       - '@azure/app-configuration'
       - '@azure/cosmos'
@@ -11038,15 +11064,27 @@ snapshots:
     transitivePeerDependencies:
       - crossws
 
-  '@tanstack/react-start@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/react-start-server@1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
     dependencies:
-      '@tanstack/react-router': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/react-start-server': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/router-utils': 1.141.0
-      '@tanstack/start-client-core': 1.141.1
-      '@tanstack/start-plugin-core': 1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
-      '@tanstack/start-server-core': 1.141.1
+      '@tanstack/history': 1.154.14
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/start-client-core': 1.158.4
+      '@tanstack/start-server-core': 1.159.0
+      react: 19.2.3
+      react-dom: 19.2.3(react@19.2.3)
+    transitivePeerDependencies:
+      - crossws
+
+  '@tanstack/react-start@1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+    dependencies:
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-start-client': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-start-server': 1.159.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/router-utils': 1.158.0
+      '@tanstack/start-client-core': 1.158.4
+      '@tanstack/start-plugin-core': 1.159.0(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/start-server-core': 1.159.0
       pathe: 2.0.3
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
@@ -11070,8 +11108,8 @@ snapshots:
       '@tanstack/history': 1.131.2
       '@tanstack/store': 0.7.7
       cookie-es: 1.2.2
-      seroval: 1.4.0
-      seroval-plugins: 1.4.0(seroval@1.4.0)
+      seroval: 1.5.0
+      seroval-plugins: 1.5.0(seroval@1.5.0)
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
@@ -11085,7 +11123,7 @@ snapshots:
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
-  '@tanstack/router-core@1.157.16':
+  '@tanstack/router-core@1.158.4':
     dependencies:
       '@tanstack/history': 1.154.14
       '@tanstack/store': 0.8.0
@@ -11095,9 +11133,9 @@ snapshots:
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
-  '@tanstack/router-devtools-core@1.141.1(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10)':
+  '@tanstack/router-devtools-core@1.141.1(@tanstack/router-core@1.158.4)(csstype@3.2.3)(solid-js@1.9.10)':
     dependencies:
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
       clsx: 2.1.1
       goober: 2.1.18(csstype@3.2.3)
       solid-js: 1.9.10
@@ -11105,9 +11143,9 @@ snapshots:
     optionalDependencies:
       csstype: 3.2.3
 
-  '@tanstack/router-devtools-core@1.157.16(@tanstack/router-core@1.157.16)(csstype@3.2.3)':
+  '@tanstack/router-devtools-core@1.158.4(@tanstack/router-core@1.158.4)(csstype@3.2.3)':
     dependencies:
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
       clsx: 2.1.1
       goober: 2.1.18(csstype@3.2.3)
       tiny-invariant: 1.3.3
@@ -11140,7 +11178,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/router-generator@1.158.4':
+    dependencies:
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/router-utils': 1.158.0
+      '@tanstack/virtual-file-routes': 1.154.7
+      prettier: 3.7.4
+      recast: 0.23.11
+      source-map: 0.7.6
+      tsx: 4.21.0
+      zod: 3.25.76
+    transitivePeerDependencies:
+      - supports-color
+
+  '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/core': 7.28.5
       '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5)
@@ -11157,13 +11208,13 @@ snapshots:
       unplugin: 2.3.11
       zod: 3.25.76
     optionalDependencies:
-      '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vite-plugin-solid: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
     transitivePeerDependencies:
       - supports-color
 
-  '@tanstack/router-plugin@1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/router-plugin@1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/core': 7.28.5
       '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5)
@@ -11180,13 +11231,13 @@ snapshots:
       unplugin: 2.3.11
       zod: 3.25.76
     optionalDependencies:
-      '@tanstack/react-router': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vite-plugin-solid: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
     transitivePeerDependencies:
       - supports-color
 
-  '@tanstack/router-plugin@1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/router-plugin@1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/core': 7.28.5
       '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5)
@@ -11194,25 +11245,29 @@ snapshots:
       '@babel/template': 7.27.2
       '@babel/traverse': 7.28.5
       '@babel/types': 7.28.5
-      '@tanstack/router-core': 1.141.1
-      '@tanstack/router-generator': 1.141.1
-      '@tanstack/router-utils': 1.141.0
-      '@tanstack/virtual-file-routes': 1.141.0
-      babel-dead-code-elimination: 1.0.10
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/router-generator': 1.158.4
+      '@tanstack/router-utils': 1.158.0
+      '@tanstack/virtual-file-routes': 1.154.7
       chokidar: 3.6.0
       unplugin: 2.3.11
       zod: 3.25.76
     optionalDependencies:
-      '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vite-plugin-solid: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
     transitivePeerDependencies:
       - supports-color
 
-  '@tanstack/router-ssr-query-core@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)':
+  '@tanstack/router-ssr-query-core@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)':
     dependencies:
       '@tanstack/query-core': 5.90.12
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
+
+  '@tanstack/router-ssr-query-core@1.158.4(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)':
+    dependencies:
+      '@tanstack/query-core': 5.90.12
+      '@tanstack/router-core': 1.158.4
 
   '@tanstack/router-utils@1.131.2':
     dependencies:
@@ -11238,6 +11293,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@tanstack/router-utils@1.158.0':
+    dependencies:
+      '@babel/core': 7.28.5
+      '@babel/generator': 7.28.5
+      '@babel/parser': 7.28.5
+      '@babel/types': 7.28.5
+      ansis: 4.2.0
+      babel-dead-code-elimination: 1.0.12
+      diff: 8.0.2
+      pathe: 2.0.3
+      tinyglobby: 0.2.15
+    transitivePeerDependencies:
+      - supports-color
+
   '@tanstack/server-functions-plugin@1.131.2(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/code-frame': 7.27.1
@@ -11284,20 +11353,20 @@ snapshots:
       '@tanstack/query-core': 5.90.12
       solid-js: 1.9.10
 
-  '@tanstack/solid-router-devtools@1.141.1(@tanstack/router-core@1.157.16)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)':
+  '@tanstack/solid-router-devtools@1.141.1(@tanstack/router-core@1.158.4)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)':
     dependencies:
-      '@tanstack/router-devtools-core': 1.141.1(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10)
+      '@tanstack/router-devtools-core': 1.141.1(@tanstack/router-core@1.158.4)(csstype@3.2.3)(solid-js@1.9.10)
       '@tanstack/solid-router': 1.141.1(solid-js@1.9.10)
       solid-js: 1.9.10
     optionalDependencies:
-      '@tanstack/router-core': 1.157.16
+      '@tanstack/router-core': 1.158.4
     transitivePeerDependencies:
       - csstype
 
-  '@tanstack/solid-router-ssr-query@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.2(jiti@2.6.1))(solid-js@1.9.10)(typescript@5.9.3)':
+  '@tanstack/solid-router-ssr-query@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.2(jiti@2.6.1))(solid-js@1.9.10)(typescript@5.9.3)':
     dependencies:
       '@tanstack/query-core': 5.90.12
-      '@tanstack/router-ssr-query-core': 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.157.16)
+      '@tanstack/router-ssr-query-core': 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.158.4)
       '@tanstack/solid-query': 5.90.15(solid-js@1.9.10)
       '@tanstack/solid-router': 1.141.1(solid-js@1.9.10)
       eslint-plugin-solid: 0.14.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -11342,13 +11411,13 @@ snapshots:
     transitivePeerDependencies:
       - crossws
 
-  '@tanstack/solid-start@1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/solid-start@1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(solid-js@1.9.10)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@tanstack/solid-router': 1.141.1(solid-js@1.9.10)
       '@tanstack/solid-start-client': 1.141.1(solid-js@1.9.10)
       '@tanstack/solid-start-server': 1.141.1(solid-js@1.9.10)
       '@tanstack/start-client-core': 1.141.1
-      '@tanstack/start-plugin-core': 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/start-plugin-core': 1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start-server-core': 1.141.1
       pathe: 2.0.3
       solid-js: 1.9.10
@@ -11366,11 +11435,11 @@ snapshots:
       '@tanstack/store': 0.8.0
       solid-js: 1.9.10
 
-  '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)':
+  '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)':
     dependencies:
       '@tanstack/router-core': 1.141.1
       '@tanstack/start-server-core': 1.141.1
-      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
     transitivePeerDependencies:
       - '@azure/app-configuration'
       - '@azure/cosmos'
@@ -11433,12 +11502,21 @@ snapshots:
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
 
-  '@tanstack/start-config@1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)':
+  '@tanstack/start-client-core@1.158.4':
     dependencies:
-      '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/start-fn-stubs': 1.154.7
+      '@tanstack/start-storage-context': 1.158.4
+      seroval: 1.5.0
+      tiny-invariant: 1.3.3
+      tiny-warning: 1.0.3
+
+  '@tanstack/start-config@1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)':
+    dependencies:
+      '@tanstack/react-router': 1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-generator': 1.141.1
-      '@tanstack/router-plugin': 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/router-plugin': 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/server-functions-plugin': 1.141.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start-server-functions-handler': 1.120.19
       '@vitejs/plugin-react': 4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -11447,7 +11525,7 @@ snapshots:
       ofetch: 1.5.1
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
-      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+      vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       zod: 3.25.76
     transitivePeerDependencies:
@@ -11499,14 +11577,16 @@ snapshots:
       - xml2js
       - yaml
 
-  '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/start-fn-stubs@1.154.7': {}
+
+  '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(rolldown@1.0.0-beta.53)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/code-frame': 7.26.2
       '@babel/core': 7.28.5
       '@babel/types': 7.28.5
       '@tanstack/router-core': 1.131.50
       '@tanstack/router-generator': 1.131.50
-      '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-utils': 1.131.2
       '@tanstack/server-functions-plugin': 1.131.2(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start-server-core': 1.131.50
@@ -11515,9 +11595,9 @@ snapshots:
       babel-dead-code-elimination: 1.0.10
       cheerio: 1.1.2
       h3: 1.13.0
-      nitropack: 2.12.9(rolldown@1.0.0-beta.53)
+      nitropack: 2.13.1(rolldown@1.0.0-beta.53)
       pathe: 2.0.3
-      ufo: 1.6.1
+      ufo: 1.6.3
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       xmlbuilder2: 3.1.1
@@ -11557,7 +11637,7 @@ snapshots:
       - webpack
       - xml2js
 
-  '@tanstack/start-plugin-core@1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/start-plugin-core@1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@babel/code-frame': 7.26.2
       '@babel/core': 7.28.5
@@ -11565,7 +11645,7 @@ snapshots:
       '@rolldown/pluginutils': 1.0.0-beta.40
       '@tanstack/router-core': 1.141.1
       '@tanstack/router-generator': 1.141.1
-      '@tanstack/router-plugin': 1.141.1(@tanstack/react-router@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/router-plugin': 1.141.1(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/router-utils': 1.141.0
       '@tanstack/server-functions-plugin': 1.141.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start-client-core': 1.141.1
@@ -11589,26 +11669,24 @@ snapshots:
       - vite-plugin-solid
       - webpack
 
-  '@tanstack/start-plugin-core@1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+  '@tanstack/start-plugin-core@1.159.0(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
-      '@babel/code-frame': 7.26.2
+      '@babel/code-frame': 7.27.1
       '@babel/core': 7.28.5
       '@babel/types': 7.28.5
       '@rolldown/pluginutils': 1.0.0-beta.40
-      '@tanstack/router-core': 1.141.1
-      '@tanstack/router-generator': 1.141.1
-      '@tanstack/router-plugin': 1.141.1(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
-      '@tanstack/router-utils': 1.141.0
-      '@tanstack/server-functions-plugin': 1.141.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
-      '@tanstack/start-client-core': 1.141.1
-      '@tanstack/start-server-core': 1.141.1
-      babel-dead-code-elimination: 1.0.10
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/router-generator': 1.158.4
+      '@tanstack/router-plugin': 1.158.4(@tanstack/react-router@1.158.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+      '@tanstack/router-utils': 1.158.0
+      '@tanstack/start-client-core': 1.158.4
+      '@tanstack/start-server-core': 1.159.0
       cheerio: 1.1.2
       exsolve: 1.0.8
       pathe: 2.0.3
-      srvx: 0.8.16
+      srvx: 0.10.1
       tinyglobby: 0.2.15
-      ufo: 1.6.1
+      ufo: 1.6.3
       vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       xmlbuilder2: 4.0.3
@@ -11631,7 +11709,7 @@ snapshots:
       isbot: 5.1.32
       tiny-invariant: 1.3.3
       tiny-warning: 1.0.3
-      unctx: 2.4.1
+      unctx: 2.5.0
 
   '@tanstack/start-server-core@1.141.1':
     dependencies:
@@ -11645,6 +11723,18 @@ snapshots:
     transitivePeerDependencies:
       - crossws
 
+  '@tanstack/start-server-core@1.159.0':
+    dependencies:
+      '@tanstack/history': 1.154.14
+      '@tanstack/router-core': 1.158.4
+      '@tanstack/start-client-core': 1.158.4
+      '@tanstack/start-storage-context': 1.158.4
+      h3-v2: h3@2.0.1-rc.11
+      seroval: 1.5.0
+      tiny-invariant: 1.3.3
+    transitivePeerDependencies:
+      - crossws
+
   '@tanstack/start-server-functions-client@1.131.50(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       '@tanstack/server-functions-plugin': 1.131.2(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -11695,13 +11785,17 @@ snapshots:
     dependencies:
       '@tanstack/router-core': 1.141.1
 
-  '@tanstack/start@1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)':
+  '@tanstack/start-storage-context@1.158.4':
+    dependencies:
+      '@tanstack/router-core': 1.158.4
+
+  '@tanstack/start@1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)':
     dependencies:
       '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+      '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       '@tanstack/react-start-server': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
-      '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
-      '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
+      '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+      '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
       '@tanstack/start-server-functions-client': 1.131.50(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
       '@tanstack/start-server-functions-handler': 1.120.19
       '@tanstack/start-server-functions-server': 1.131.2(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -11774,6 +11868,8 @@ snapshots:
 
   '@tanstack/virtual-file-routes@1.141.0': {}
 
+  '@tanstack/virtual-file-routes@1.154.7': {}
+
   '@tanstack/vite-config@0.4.1(@types/node@24.10.3)(rollup@4.55.1)(typescript@5.9.3)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
       rollup-plugin-preserve-directives: 0.4.0(rollup@4.55.1)
@@ -12149,7 +12245,7 @@ snapshots:
       node-forge: 1.3.3
       pathe: 1.1.2
       std-env: 3.10.0
-      ufo: 1.6.1
+      ufo: 1.6.3
       untun: 0.1.3
       uqr: 0.1.2
 
@@ -12684,6 +12780,15 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  babel-dead-code-elimination@1.0.12:
+    dependencies:
+      '@babel/core': 7.28.5
+      '@babel/parser': 7.28.5
+      '@babel/traverse': 7.28.5
+      '@babel/types': 7.28.5
+    transitivePeerDependencies:
+      - supports-color
+
   babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.28.5):
     dependencies:
       '@babel/core': 7.28.5
@@ -14008,7 +14113,7 @@ snapshots:
       iron-webcrypto: 1.2.1
       ohash: 1.1.6
       radix3: 1.1.2
-      ufo: 1.6.1
+      ufo: 1.6.3
       uncrypto: 0.1.3
       unenv: 1.10.0
 
@@ -14043,6 +14148,11 @@ snapshots:
       rou3: 0.7.12
       srvx: 0.8.16
 
+  h3@2.0.1-rc.11:
+    dependencies:
+      rou3: 0.7.12
+      srvx: 0.10.1
+
   happy-dom@20.0.11:
     dependencies:
       '@types/node': 20.19.26
@@ -16537,6 +16647,8 @@ snapshots:
 
   sprintf-js@1.0.3: {}
 
+  srvx@0.10.1: {}
+
   srvx@0.8.16: {}
 
   stable-hash-x@0.2.0: {}
@@ -17074,20 +17186,6 @@ snapshots:
       db0: 0.3.4
       ioredis: 5.8.2
 
-  unstorage@1.17.3(db0@0.3.4)(ioredis@5.9.2):
-    dependencies:
-      anymatch: 3.1.3
-      chokidar: 4.0.3
-      destr: 2.0.5
-      h3: 1.15.4
-      lru-cache: 10.4.3
-      node-fetch-native: 1.6.7
-      ofetch: 1.5.1
-      ufo: 1.6.1
-    optionalDependencies:
-      db0: 0.3.4
-      ioredis: 5.9.2
-
   unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2):
     dependencies:
       anymatch: 3.1.3
@@ -17169,7 +17267,7 @@ snapshots:
       '@types/unist': 3.0.3
       vfile-message: 4.0.3
 
-  vinxi@0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
+  vinxi@0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-beta.53)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
     dependencies:
       '@babel/core': 7.28.5
       '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5)
@@ -17191,7 +17289,7 @@ snapshots:
       hookable: 5.5.3
       http-proxy: 1.18.1
       micromatch: 4.0.8
-      nitropack: 2.12.9(rolldown@1.0.0-beta.53)
+      nitropack: 2.13.1(rolldown@1.0.0-beta.53)
       node-fetch-native: 1.6.7
       path-to-regexp: 6.3.0
       pathe: 1.1.2
@@ -17202,7 +17300,7 @@ snapshots:
       ufo: 1.6.1
       unctx: 2.4.1
       unenv: 1.10.0
-      unstorage: 1.17.3(db0@0.3.4)(ioredis@5.9.2)
+      unstorage: 1.17.3(db0@0.3.4)(ioredis@5.8.2)
       vite: 6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
       zod: 3.25.76
     transitivePeerDependencies:
diff --git a/scripts/fix-version-bump.ts b/scripts/fix-version-bump.ts
new file mode 100644
index 000000000..8a5c31032
--- /dev/null
+++ b/scripts/fix-version-bump.ts
@@ -0,0 +1,156 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { glob } from 'tinyglobby'
+
+const WRONG_VERSION = '1.0.0'
+
+interface PackageToFix {
+  name: string
+  packageJsonPath: string
+  changelogPath: string
+  detectedVersion: string | null
+}
+
+function parseArgs(): { version: string | null } {
+  const args = process.argv.slice(2)
+  const versionIndex = args.findIndex(
+    (arg) => arg === '--version' || arg === '-v',
+  )
+
+  if (versionIndex !== -1 && args[versionIndex + 1]) {
+    return { version: args[versionIndex + 1] }
+  }
+
+  return { version: null }
+}
+
+function detectVersionFromChangelog(changelogPath: string): string | null {
+  try {
+    const content = readFileSync(changelogPath, 'utf-8')
+
+    // Look for "Updated dependencies" section and extract version numbers
+    // Pattern: - @tanstack/package-name@X.Y.Z
+    const dependencyPattern = /-\s+@tanstack\/[\w-]+@(\d+\.\d+\.\d+)/g
+    const matches = [...content.matchAll(dependencyPattern)]
+
+    if (matches.length > 0) {
+      // Get the first dependency version (they should all be the same in a changeset bump)
+      const version = matches[0][1]
+      // Make sure it's not also 1.0.0
+      if (version !== WRONG_VERSION) {
+        return version
+      }
+    }
+
+    return null
+  } catch {
+    return null
+  }
+}
+
+function fixPackageJson(path: string, newVersion: string): void {
+  const content = readFileSync(path, 'utf-8')
+  const updated = content.replace(
+    /"version":\s*"1\.0\.0"/,
+    `"version": "${newVersion}"`,
+  )
+  writeFileSync(path, updated)
+}
+
+function fixChangelog(path: string, newVersion: string): void {
+  const content = readFileSync(path, 'utf-8')
+  // Replace the first occurrence of "## 1.0.0" with the correct version
+  const updated = content.replace(/^## 1\.0\.0$/m, `## ${newVersion}`)
+  writeFileSync(path, updated)
+}
+
+async function main() {
+  const { version: cliVersion } = parseArgs()
+
+  console.log('🔍 Scanning for packages with version 1.0.0...\n')
+
+  // Find all package.json files in packages/typescript
+  const packageJsonFiles = await glob('packages/typescript/*/package.json', {
+    ignore: ['**/node_modules/**'],
+  })
+
+  const packagesToFix: PackageToFix[] = []
+
+  for (const packageJsonPath of packageJsonFiles) {
+    try {
+      const content = readFileSync(packageJsonPath, 'utf-8')
+      const pkg = JSON.parse(content)
+
+      if (pkg.version === WRONG_VERSION) {
+        const changelogPath = packageJsonPath.replace(
+          'package.json',
+          'CHANGELOG.md',
+        )
+        const detectedVersion = detectVersionFromChangelog(changelogPath)
+
+        packagesToFix.push({
+          name: pkg.name,
+          packageJsonPath,
+          changelogPath,
+          detectedVersion,
+        })
+      }
+    } catch {
+      // Skip files that can't be parsed
+    }
+  }
+
+  if (packagesToFix.length === 0) {
+    console.log('✅ No packages found with version 1.0.0. Nothing to fix!')
+    process.exit(0)
+  }
+
+  console.log(`Found ${packagesToFix.length} package(s) with version 1.0.0:\n`)
+
+  const errors: string[] = []
+
+  for (const pkg of packagesToFix) {
+    const targetVersion = cliVersion || pkg.detectedVersion
+
+    if (!targetVersion) {
+      errors.push(pkg.name)
+      console.log(`  ❌ ${pkg.name} - could not auto-detect version`)
+    } else {
+      console.log(`  📦 ${pkg.name} → ${targetVersion}`)
+    }
+  }
+
+  if (errors.length > 0) {
+    console.log('\n❌ Could not auto-detect version for some packages.')
+    console.log('   Please run with an explicit version:\n')
+    console.log('   node scripts/fix-version-bump.ts --version X.Y.Z\n')
+    process.exit(1)
+  }
+
+  console.log('\n🔧 Fixing versions...\n')
+
+  for (const pkg of packagesToFix) {
+    const targetVersion = cliVersion || pkg.detectedVersion!
+
+    // Fix package.json
+    fixPackageJson(pkg.packageJsonPath, targetVersion)
+    console.log(`  ✓ ${pkg.packageJsonPath}`)
+
+    // Fix CHANGELOG.md
+    try {
+      fixChangelog(pkg.changelogPath, targetVersion)
+      console.log(`  ✓ ${pkg.changelogPath}`)
+    } catch {
+      console.log(`  ⚠ ${pkg.changelogPath} (not found or could not update)`)
+    }
+  }
+
+  console.log('\n✅ Done! Version bump fixed.')
+  console.log('\nNext steps:')
+  console.log('  1. Review the changes: git diff')
+  console.log(
+    '  2. Commit: git add -A && git commit -m "fix: correct version bump to X.Y.Z"',
+  )
+}
+
+main().catch(console.error)
diff --git a/testing/panel/package.json b/testing/panel/package.json
index 688207804..53628b981 100644
--- a/testing/panel/package.json
+++ b/testing/panel/package.json
@@ -21,9 +21,9 @@
     "@tanstack/ai-openrouter": "workspace:*",
     "@tanstack/ai-react": "workspace:*",
     "@tanstack/ai-react-ui": "workspace:*",
-    "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
-    "@tanstack/react-router": "^1.141.1",
-    "@tanstack/react-start": "^1.141.1",
+    "@tanstack/nitro-v2-vite-plugin": "^1.154.7",
+    "@tanstack/react-router": "^1.158.4",
+    "@tanstack/react-start": "^1.159.0",
     "@tanstack/start": "^1.120.20",
     "highlight.js": "^11.11.1",
     "lucide-react": "^0.561.0",
diff --git a/testing/panel/src/lib/model-selection.ts b/testing/panel/src/lib/model-selection.ts
index 1efc2ab38..470c6a52d 100644
--- a/testing/panel/src/lib/model-selection.ts
+++ b/testing/panel/src/lib/model-selection.ts
@@ -83,7 +83,7 @@ export const MODEL_OPTIONS: Array<ModelOption> = [
   {
     provider: 'grok',
     model: 'grok-4',
-    label: 'Grok - Grok 4',
+    label: 'Grok - Grok 4 - slow thinking',
   },
   {
     provider: 'grok',
diff --git a/testing/panel/tests/tool-flow.spec.ts b/testing/panel/tests/tool-flow.spec.ts
index 3c940d7a5..0879d92d6 100644
--- a/testing/panel/tests/tool-flow.spec.ts
+++ b/testing/panel/tests/tool-flow.spec.ts
@@ -154,6 +154,85 @@ for (const provider of toolProviders) {
   })
 }
 
+// ===========================
+// Multi-turn follow-up tests
+// ===========================
+// These test that providers can handle follow-up messages AFTER tool calls.
+// This specifically catches the Anthropic bug where consecutive user-role messages
+// (tool_result + new user message) violate the alternating role constraint.
+
+for (const provider of toolProviders) {
+  test.describe(`${provider.name} - Multi-turn Tool Follow-up`, () => {
+    // Extended timeout for multi-turn conversations (two full LLM round-trips)
+    test.describe.configure({ retries: 2, timeout: 180_000 })
+
+    // Skip if provider is not available
+    test.skip(
+      () => !isProviderAvailable(provider),
+      `${provider.name} API key not configured (requires ${provider.envKey || 'no key'})`,
+    )
+
+    test('should handle follow-up message after tool call completes', async ({
+      page,
+    }) => {
+      // Navigate to the chat page
+      await goToChatPage(page)
+
+      // Select the provider and model
+      await selectProvider(page, provider.id, provider.defaultModel)
+
+      // First message: trigger a tool call
+      await sendMessage(
+        page,
+        'Use the getGuitars tool to show me what acoustic guitars you have.',
+      )
+
+      // Wait for the first response to complete (tool call + model response)
+      await waitForResponse(page, 120_000)
+
+      // Verify the first turn worked - should have tool calls
+      const firstMessages = await getMessages(page)
+      const firstAssistant = firstMessages.filter(
+        (m: any) => m.role === 'assistant',
+      )
+      expect(firstAssistant.length).toBeGreaterThan(0)
+
+      // Send a follow-up message - this is where the bug manifested
+      // With the Anthropic bug, this would fail with consecutive user-role messages
+      await sendMessage(page, 'Now tell me about electric guitars instead.')
+
+      // Wait for the follow-up response
+      await waitForResponse(page, 120_000)
+
+      // Get all messages after the follow-up
+      const allMessages = await getMessages(page)
+
+      // Should have at least 2 user messages and 2 assistant messages
+      const userMessages = allMessages.filter((m: any) => m.role === 'user')
+      const assistantMessages = allMessages.filter(
+        (m: any) => m.role === 'assistant',
+      )
+
+      expect(userMessages.length).toBeGreaterThanOrEqual(2)
+      expect(assistantMessages.length).toBeGreaterThanOrEqual(2)
+
+      // The LAST assistant message should have non-empty text content
+      // (not just tool calls, and not an error)
+      const lastAssistant = assistantMessages[assistantMessages.length - 1]
+      const textParts = lastAssistant.parts?.filter(
+        (p: any) => p.type === 'text' && p.content && p.content.length > 0,
+      )
+
+      // The follow-up should have produced some text OR tool calls
+      // (both are valid responses - the key is it didn't error out)
+      const hasText = textParts?.length > 0
+      const hasTools =
+        lastAssistant.parts?.some((p: any) => p.type === 'tool-call') || false
+      expect(hasText || hasTools).toBe(true)
+    })
+  })
+}
+
 // Verify we have tool-capable providers to test
 test('at least one tool-capable provider should be available', async () => {
   const available = getToolCapableProviders()
