From d0ac36c1b6ab2a71a87d6f7691175228cc25a89f Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 12:34:02 -0600
Subject: [PATCH 01/14] fix(core): drop orphan reasoning parts

---
 .../core/src/agent/message-normalizer.spec.ts | 92 +++++++++++++++++++
 packages/core/src/agent/message-normalizer.ts | 37 +++++++-
 .../src/memory/manager/memory-manager.spec.ts | 42 +++++++++
 3 files changed, 169 insertions(+), 2 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index b79d8666f..03dfab673 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -428,6 +428,98 @@ describe("message-normalizer", () => {
     expect(sanitized[0].role).toBe("user");
   });
 
+  it("drops orphaned empty reasoning when incomplete tool calls are pruned", () => {
+    const messages: UIMessage[] = [
+      baseMessage([
+        {
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_123" } },
+        } as any,
+        {
+          type: "tool-search",
+          toolCallId: "call-123",
+          state: "input-available",
+          input: { query: "hello" },
+        } as any,
+      ]),
+      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+    ];
+
+    const sanitized = sanitizeMessagesForModel(messages);
+
+    expect(sanitized).toHaveLength(1);
+    expect(sanitized[0].role).toBe("user");
+  });
+
+  it("drops empty reasoning when assistant message only contains tool output", () => {
+    const messages: UIMessage[] = [
+      baseMessage([
+        {
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_123" } },
+        } as any,
+        {
+          type: "tool-search",
+          toolCallId: "call-456",
+          state: "output-available",
+          output: { results: [] },
+        } as any,
+      ]),
+      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+    ];
+
+    const sanitized = sanitizeMessagesForModel(messages);
+
+    expect(sanitized).toHaveLength(2);
+    expect(sanitized[0].role).toBe("assistant");
+    expect(sanitized[0].parts.some((part) => part.type === "reasoning")).toBe(false);
+    expect(sanitized[1].role).toBe("user");
+  });
+
+  it("drops empty reasoning that precedes working-memory tool calls", () => {
+    const message = baseMessage([
+      {
+        type: "reasoning",
+        text: "Planning next steps.",
+        providerMetadata: { openai: { itemId: "rs_text" } },
+      } as any,
+      {
+        type: "tool-search",
+        toolCallId: "call-1",
+        state: "output-available",
+        output: { result: true },
+      } as any,
+      {
+        type: "reasoning",
+        text: "",
+        providerMetadata: { openai: { itemId: "rs_empty" } },
+      } as any,
+      {
+        type: "tool-update_working_memory",
+        toolCallId: "call-2",
+        state: "output-available",
+        output: { type: "text", value: "ok" },
+      } as any,
+      { type: "step-start" } as any,
+      { type: "text", text: "final answer" } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    const parts = (sanitized as UIMessage).parts;
+    expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false);
+    expect(
+      parts.some(
+        (part: any) =>
+          part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty",
+      ),
+    ).toBe(false);
+    expect(parts.some((part: any) => part.type === "reasoning")).toBe(true);
+    expect(parts.some((part: any) => part.type === "text")).toBe(true);
+  });
+
   it("preserves approval responses on the last assistant message", () => {
     const messages: UIMessage[] = [
       baseMessage([
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index b27fd13a6..c4b2b5979 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -354,7 +354,20 @@ export const sanitizeMessagesForModel = (
 export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => {
   const sanitizedParts: UIMessagePart<any, any>[] = [];
 
-  for (const part of message.parts) {
+  for (let index = 0; index < message.parts.length; index += 1) {
+    const part = message.parts[index];
+    const nextPart = message.parts[index + 1];
+    if (part?.type === "reasoning") {
+      const text = typeof (part as any).text === "string" ? (part as any).text.trim() : "";
+      if (
+        text.length === 0 &&
+        nextPart &&
+        isToolLikePart(nextPart as UIMessagePart<any, any>) &&
+        isWorkingMemoryTool(nextPart as ToolLikePart)
+      ) {
+        continue;
+      }
+    }
     const normalized = normalizeGenericPart(part);
     if (!normalized) {
       continue;
@@ -462,7 +475,27 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] =
       return true;
     });
 
-    const pruned = collapseRedundantStepStarts(parts);
+    let pruned = collapseRedundantStepStarts(parts);
+    const hasTextContent = pruned.some(
+      (part) =>
+        part.type === "text" &&
+        typeof (part as any).text === "string" &&
+        (part as any).text.trim().length > 0,
+    );
+    const hasToolPart = pruned.some((part) => isToolLikePart(part));
+    const hasNonReasoningPart = pruned.some((part) => part.type !== "reasoning");
+    const shouldDropEmptyReasoning =
+      (!hasTextContent && hasToolPart) || (mutated && !hasNonReasoningPart);
+
+    if (shouldDropEmptyReasoning) {
+      pruned = pruned.filter((part) => {
+        if (part.type !== "reasoning") {
+          return true;
+        }
+        const text = typeof (part as any).text === "string" ? (part as any).text.trim() : "";
+        return text.length > 0;
+      });
+    }
     if (pruned.length === 0) {
       continue;
     }
diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts
index 3750e83d5..bfc633b08 100644
--- a/packages/core/src/memory/manager/memory-manager.spec.ts
+++ b/packages/core/src/memory/manager/memory-manager.spec.ts
@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { sanitizeMessagesForModel } from "../../agent/message-normalizer";
 import type { OperationContext } from "../../agent/types";
 import { getGlobalLogger } from "../../logger";
 import { Memory } from "../../memory";
@@ -161,6 +162,47 @@ describe("MemoryManager", () => {
       expect(messages[1].id).toBe("msg-2");
     });
 
+    it("drops empty reasoning that precedes working-memory tool calls on reload", async () => {
+      const context = createMockOperationContext();
+
+      const assistantMessage = createTestUIMessage({
+        id: "assistant-1",
+        role: "assistant",
+        parts: [
+          {
+            type: "reasoning",
+            text: "",
+            providerMetadata: { openai: { itemId: "rs_123" } },
+          } as any,
+          {
+            type: "tool-search",
+            toolCallId: "call-123",
+            state: "input-available",
+            input: { query: "hello" },
+          } as any,
+          {
+            type: "tool-update_working_memory",
+            toolCallId: "call-999",
+            state: "output-available",
+            output: { type: "text", value: "ok" },
+          } as any,
+        ],
+      });
+
+      await manager.saveMessage(context, assistantMessage, "user-1", "conv-1");
+
+      const reloaded = await manager.getMessages(context, "user-1", "conv-1");
+      const nextTurn = {
+        id: "user-2",
+        role: "user",
+        parts: [{ type: "text", text: "next turn" }],
+      };
+
+      const sanitized = sanitizeMessagesForModel([...reloaded, nextTurn]);
+      expect(sanitized).toHaveLength(1);
+      expect(sanitized[0].role).toBe("user");
+    });
+
     it("should return empty array when memory is disabled", async () => {
       const disabledManager = new MemoryManager("agent-3", false);
 

From c685e24b816586bfcc24d1e1fe32ddc2c759d47b Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 12:35:34 -0600
Subject: [PATCH 02/14] chore: add changeset for reasoning fix

---
 .changeset/drop-orphan-reasoning.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/drop-orphan-reasoning.md

diff --git a/.changeset/drop-orphan-reasoning.md b/.changeset/drop-orphan-reasoning.md
new file mode 100644
index 000000000..8c6b7bdf4
--- /dev/null
+++ b/.changeset/drop-orphan-reasoning.md
@@ -0,0 +1,5 @@
+---
+"@voltagent/core": patch
+---
+
+Drop orphan reasoning parts when tool-only messages are sanitized.

From 0ef15294b9bff485a07b0c16d58f28d27099c565 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 12:37:50 -0600
Subject: [PATCH 03/14] chore(changeset): reference issue

---
 .changeset/drop-orphan-reasoning.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.changeset/drop-orphan-reasoning.md b/.changeset/drop-orphan-reasoning.md
index 8c6b7bdf4..7e0567fb5 100644
--- a/.changeset/drop-orphan-reasoning.md
+++ b/.changeset/drop-orphan-reasoning.md
@@ -3,3 +3,4 @@
 ---
 
 Drop orphan reasoning parts when tool-only messages are sanitized.
+Fixes #1019.

From a4eaa7666aa4912631e240be7ce083c4befdaa17 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 13:04:04 -0600
Subject: [PATCH 04/14] fix(core): skip pruned parts when dropping reasoning

---
 .../core/src/agent/message-normalizer.spec.ts | 33 ++++++++++
 packages/core/src/agent/message-normalizer.ts | 62 ++++++++++++++-----
 2 files changed, 80 insertions(+), 15 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 03dfab673..b43dfe2b1 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -520,6 +520,39 @@ describe("message-normalizer", () => {
     expect(parts.some((part: any) => part.type === "text")).toBe(true);
   });
 
+  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
+    const message = baseMessage([
+      {
+        type: "reasoning",
+        text: "",
+        providerMetadata: { openai: { itemId: "rs_empty" } },
+      } as any,
+      {
+        type: "tool-search",
+        toolCallId: "call-ghost",
+      } as any,
+      {
+        type: "tool-update_working_memory",
+        toolCallId: "call-mem",
+        state: "output-available",
+        output: { type: "text", value: "ok" },
+      } as any,
+      { type: "text", text: "final answer" } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    const parts = (sanitized as UIMessage).parts;
+    expect(
+      parts.some(
+        (part: any) =>
+          part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty",
+      ),
+    ).toBe(false);
+    expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false);
+    expect(parts.some((part: any) => part.type === "text")).toBe(true);
+  });
+
   it("preserves approval responses on the last assistant message", () => {
     const messages: UIMessage[] = [
       baseMessage([
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index c4b2b5979..7bd16f557 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -351,21 +351,45 @@ export const sanitizeMessagesForModel = (
   return addStepStartsBetweenToolRuns(filtered);
 };
 
+const findNextContentPart = (
+  parts: UIMessagePart<any, any>[],
+  startIndex: number,
+): { kind: "working-memory" } | { kind: "part"; part: UIMessagePart<any, any> } | undefined => {
+  for (let index = startIndex; index < parts.length; index += 1) {
+    const candidate = parts[index];
+    if (!candidate) {
+      continue;
+    }
+    if (candidate.type === "step-start") {
+      continue;
+    }
+    if (isToolLikePart(candidate) && isWorkingMemoryTool(candidate as ToolLikePart)) {
+      return { kind: "working-memory" };
+    }
+    const normalized = normalizeGenericPart(candidate);
+    if (!normalized) {
+      continue;
+    }
+    if (isPrunableEmptyToolRun(normalized)) {
+      continue;
+    }
+    return { kind: "part", part: normalized };
+  }
+  return undefined;
+};
+
 export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => {
   const sanitizedParts: UIMessagePart<any, any>[] = [];
 
   for (let index = 0; index < message.parts.length; index += 1) {
     const part = message.parts[index];
-    const nextPart = message.parts[index + 1];
     if (part?.type === "reasoning") {
       const text = typeof (part as any).text === "string" ? (part as any).text.trim() : "";
-      if (
-        text.length === 0 &&
-        nextPart &&
-        isToolLikePart(nextPart as UIMessagePart<any, any>) &&
-        isWorkingMemoryTool(nextPart as ToolLikePart)
-      ) {
-        continue;
+      if (text.length === 0) {
+        const nextContent = findNextContentPart(message.parts, index + 1);
+        if (nextContent?.kind === "working-memory") {
+          continue;
+        }
       }
     }
     const normalized = normalizeGenericPart(part);
@@ -558,13 +582,8 @@ const addStepStartsBetweenToolRuns = (messages: UIMessage[]): UIMessage[] => {
 const pruneEmptyToolRuns = (parts: UIMessagePart<any, any>[]): UIMessagePart<any, any>[] => {
   const cleaned: UIMessagePart<any, any>[] = [];
   for (const part of parts) {
-    if (typeof part.type === "string" && part.type.startsWith("tool-")) {
-      const hasPendingState = (part as any).state === "input-available";
-      const hasResult =
-        (part as any).state === "output-available" || (part as any).output !== undefined;
-      if (!hasPendingState && !hasResult && (part as any).input == null) {
-        continue;
-      }
+    if (isPrunableEmptyToolRun(part)) {
+      continue;
     }
 
     cleaned.push(part);
@@ -572,6 +591,19 @@ const pruneEmptyToolRuns = (parts: UIMessagePart<any, any>[]): UIMessagePart<any
   return cleaned;
 };
 
+const isPrunableEmptyToolRun = (part: UIMessagePart<any, any>): boolean => {
+  if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
+    return false;
+  }
+  const hasPendingState = (part as any).state === "input-available";
+  const hasResult =
+    (part as any).state === "output-available" || (part as any).output !== undefined;
+  if (!hasPendingState && !hasResult && (part as any).input == null) {
+    return true;
+  }
+  return false;
+};
+
 const removeProviderExecutedToolsWithoutReasoning = (
   parts: UIMessagePart<any, any>[],
 ): UIMessagePart<any, any>[] => {

From 537749bcdbf6c337617a76c5544ad1d38d2ade12 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 13:22:48 -0600
Subject: [PATCH 05/14] fix(core): preserve tool run error states

---
 .../core/src/agent/message-normalizer.spec.ts | 31 +++++++++++++++++++
 packages/core/src/agent/message-normalizer.ts |  6 ++--
 2 files changed, 34 insertions(+), 3 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index b43dfe2b1..35854e60e 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -147,6 +147,37 @@ describe("message-normalizer", () => {
     expect(((sanitized as UIMessage).parts[0] as any).state).toBe("input-available");
   });
 
+  it("keeps tool runs with pending, error, or denied states", () => {
+    const message = baseMessage([
+      {
+        type: "tool-search",
+        toolCallId: "call-streaming",
+        state: "input-streaming",
+        input: { query: "streaming" },
+      } as any,
+      {
+        type: "tool-search",
+        toolCallId: "call-error",
+        state: "output-error",
+        errorText: "Tool failed",
+      } as any,
+      {
+        type: "tool-search",
+        toolCallId: "call-denied",
+        state: "output-denied",
+      } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    const parts = (sanitized as UIMessage).parts as any[];
+    expect(parts.map((part) => part.state)).toEqual([
+      "input-streaming",
+      "output-error",
+      "output-denied",
+    ]);
+  });
+
   it("preserves tool approval metadata for approval flows", () => {
     const message = baseMessage([
       {
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 7bd16f557..3cd1648af 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -595,9 +595,9 @@ const isPrunableEmptyToolRun = (part: UIMessagePart<any, any>): boolean => {
   if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
     return false;
   }
-  const hasPendingState = (part as any).state === "input-available";
-  const hasResult =
-    (part as any).state === "output-available" || (part as any).output !== undefined;
+  const state = (part as any).state;
+  const hasPendingState = state === "input-available" || state === "input-streaming";
+  const hasResult = hasToolOutput(part as ToolLikePart);
   if (!hasPendingState && !hasResult && (part as any).input == null) {
     return true;
   }

From 06dd974a77276d24165f40e2eefedf38f6ea0419 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 16:42:19 -0600
Subject: [PATCH 06/14] fix(core): treat output-streaming as tool result

---
 packages/core/src/agent/message-normalizer.spec.ts | 14 ++++++++++++++
 packages/core/src/agent/message-normalizer.ts      |  7 ++++++-
 2 files changed, 20 insertions(+), 1 deletion(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 35854e60e..741a063f5 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -178,6 +178,20 @@ describe("message-normalizer", () => {
     ]);
   });
 
+  it("keeps tool runs with output-streaming state", () => {
+    const message = baseMessage([
+      {
+        type: "tool-search",
+        toolCallId: "call-streaming-output",
+        state: "output-streaming",
+      } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    expect(((sanitized as UIMessage).parts[0] as any).state).toBe("output-streaming");
+  });
+
   it("preserves tool approval metadata for approval flows", () => {
     const message = baseMessage([
       {
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 3cd1648af..409e34139 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -261,7 +261,12 @@ const isToolLikePart = (part: UIMessagePart<any, any>): part is ToolLikePart =>
 
 const hasToolOutput = (part: ToolLikePart): boolean => {
   const state = typeof part.state === "string" ? part.state : undefined;
-  if (state === "output-available" || state === "output-error" || state === "output-denied") {
+  if (
+    state === "output-available" ||
+    state === "output-streaming" ||
+    state === "output-error" ||
+    state === "output-denied"
+  ) {
     return true;
   }
   return part.output !== undefined;

From f96df507c2955ff2ff79620dc9bb96efe5d2fda6 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 16:50:08 -0600
Subject: [PATCH 07/14] fix(core): drop reasoning before memory tools

---
 packages/core/src/agent/message-normalizer.spec.ts | 8 ++++----
 packages/core/src/agent/message-normalizer.ts      | 9 +++------
 2 files changed, 7 insertions(+), 10 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 741a063f5..02beab7a1 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -523,7 +523,7 @@ describe("message-normalizer", () => {
     expect(sanitized[1].role).toBe("user");
   });
 
-  it("drops empty reasoning that precedes working-memory tool calls", () => {
+  it("drops reasoning that precedes working-memory tool calls", () => {
     const message = baseMessage([
       {
         type: "reasoning",
@@ -538,7 +538,7 @@ describe("message-normalizer", () => {
       } as any,
       {
         type: "reasoning",
-        text: "",
+        text: "Updating memory.",
         providerMetadata: { openai: { itemId: "rs_empty" } },
       } as any,
       {
@@ -565,11 +565,11 @@ describe("message-normalizer", () => {
     expect(parts.some((part: any) => part.type === "text")).toBe(true);
   });
 
-  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
+  it("drops reasoning when prunable parts sit before working-memory tools", () => {
     const message = baseMessage([
       {
         type: "reasoning",
-        text: "",
+        text: "Prepping memory update.",
         providerMetadata: { openai: { itemId: "rs_empty" } },
       } as any,
       {
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 409e34139..794606ca5 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -389,12 +389,9 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null =>
   for (let index = 0; index < message.parts.length; index += 1) {
     const part = message.parts[index];
     if (part?.type === "reasoning") {
-      const text = typeof (part as any).text === "string" ? (part as any).text.trim() : "";
-      if (text.length === 0) {
-        const nextContent = findNextContentPart(message.parts, index + 1);
-        if (nextContent?.kind === "working-memory") {
-          continue;
-        }
+      const nextContent = findNextContentPart(message.parts, index + 1);
+      if (nextContent?.kind === "working-memory") {
+        continue;
       }
     }
     const normalized = normalizeGenericPart(part);

From 7efcd71e926071eb891ed48e1918483cae0431b4 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Wed, 4 Feb 2026 17:18:18 -0600
Subject: [PATCH 08/14] fix(core): only drop empty reasoning before memory
 tools

---
 packages/core/src/agent/message-normalizer.spec.ts |  8 ++++----
 packages/core/src/agent/message-normalizer.ts      | 12 +++++++++++-
 2 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 02beab7a1..741a063f5 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -523,7 +523,7 @@ describe("message-normalizer", () => {
     expect(sanitized[1].role).toBe("user");
   });
 
-  it("drops reasoning that precedes working-memory tool calls", () => {
+  it("drops empty reasoning that precedes working-memory tool calls", () => {
     const message = baseMessage([
       {
         type: "reasoning",
@@ -538,7 +538,7 @@ describe("message-normalizer", () => {
       } as any,
       {
         type: "reasoning",
-        text: "Updating memory.",
+        text: "",
         providerMetadata: { openai: { itemId: "rs_empty" } },
       } as any,
       {
@@ -565,11 +565,11 @@ describe("message-normalizer", () => {
     expect(parts.some((part: any) => part.type === "text")).toBe(true);
   });
 
-  it("drops reasoning when prunable parts sit before working-memory tools", () => {
+  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
     const message = baseMessage([
       {
         type: "reasoning",
-        text: "Prepping memory update.",
+        text: "",
         providerMetadata: { openai: { itemId: "rs_empty" } },
       } as any,
       {
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 794606ca5..94773d06a 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -389,8 +389,18 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null =>
   for (let index = 0; index < message.parts.length; index += 1) {
     const part = message.parts[index];
     if (part?.type === "reasoning") {
+      const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined;
+      const content =
+        typeof (part as any).content === "string" ? (part as any).content.trim() : undefined;
+      const id =
+        typeof (part as any).id === "string"
+          ? (part as any).id.trim()
+          : typeof (part as any).reasoningId === "string"
+            ? (part as any).reasoningId.trim()
+            : undefined;
+      const isEmptyReasoning = !text && !content && !id;
       const nextContent = findNextContentPart(message.parts, index + 1);
-      if (nextContent?.kind === "working-memory") {
+      if (isEmptyReasoning && nextContent?.kind === "working-memory") {
         continue;
       }
     }

From 3243ecc2124dcf5b06576aca594b28ab2b1b52a0 Mon Sep 17 00:00:00 2001
From: Chris Sims <126508279+chrisisagile@users.noreply.github.com>
Date: Wed, 4 Feb 2026 17:26:04 -0600
Subject: [PATCH 09/14] fix(core): normalize reasoning before working-memory
 tools

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
---
 packages/core/src/agent/message-normalizer.ts | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 94773d06a..4cf24b221 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -392,12 +392,16 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null =>
       const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined;
       const content =
         typeof (part as any).content === "string" ? (part as any).content.trim() : undefined;
-      const id =
+      const explicitId =
         typeof (part as any).id === "string"
           ? (part as any).id.trim()
           : typeof (part as any).reasoningId === "string"
             ? (part as any).reasoningId.trim()
             : undefined;
+      const providerMetadata = (part as any).providerMetadata;
+      const metadataReasoningId =
+        isObject(providerMetadata) ? extractReasoningIdFromMetadata(providerMetadata) : undefined;
+      const id = explicitId || metadataReasoningId;
       const isEmptyReasoning = !text && !content && !id;
       const nextContent = findNextContentPart(message.parts, index + 1);
       if (isEmptyReasoning && nextContent?.kind === "working-memory") {

From ed731fd644b3899567f7bec73a2f75298596e0fa Mon Sep 17 00:00:00 2001
From: "chris@rhythmic.com" <chris@gorhythmic.com>
Date: Thu, 5 Feb 2026 11:27:53 -0600
Subject: [PATCH 10/14] fix(core): drop empty reasoning before working-memory
 tools

---
 .../core/src/agent/message-normalizer.spec.ts |  48 +++++
 packages/core/src/agent/message-normalizer.ts | 190 ++++++++++--------
 2 files changed, 150 insertions(+), 88 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 741a063f5..86253d93e 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -643,4 +643,52 @@ describe("message-normalizer", () => {
     expect(sanitized[0].parts).toHaveLength(3);
     expect(sanitized[0].parts[1]).toEqual({ type: "step-start" });
   });
+
+  it("drops empty reasoning that precedes working-memory tool calls", () => {
+    const message = baseMessage([
+      {
+        type: "reasoning",
+        text: "",
+        providerMetadata: { openai: { itemId: "rs_openai" } },
+      } as any,
+      {
+        type: "tool-update_working_memory",
+        toolCallId: "call-1",
+        state: "output-available",
+        output: { content: "secret" },
+      } as any,
+      { type: "text", text: "visible" } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    const parts = (sanitized as UIMessage).parts;
+    expect(parts.some((part: any) => part.type === "reasoning")).toBe(false);
+    expect(parts.some((part: any) => part.type === "text")).toBe(true);
+  });
+
+  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
+    const message = baseMessage([
+      {
+        type: "reasoning",
+        text: "",
+        providerMetadata: { openai: { itemId: "rs_openai" } },
+      } as any,
+      { type: "text", text: "   " } as any,
+      { type: "step-start" } as any,
+      {
+        type: "tool-update_working_memory",
+        toolCallId: "call-2",
+        state: "input-available",
+        input: { content: "secret" },
+      } as any,
+      { type: "text", text: "visible" } as any,
+    ]);
+
+    const sanitized = sanitizeMessageForModel(message);
+    expect(sanitized).not.toBeNull();
+    const parts = (sanitized as UIMessage).parts;
+    expect(parts.some((part: any) => part.type === "reasoning")).toBe(false);
+    expect(parts.some((part: any) => part.type === "text")).toBe(true);
+  });
 });
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 4cf24b221..a84843c0c 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -261,12 +261,7 @@ const isToolLikePart = (part: UIMessagePart<any, any>): part is ToolLikePart =>
 
 const hasToolOutput = (part: ToolLikePart): boolean => {
   const state = typeof part.state === "string" ? part.state : undefined;
-  if (
-    state === "output-available" ||
-    state === "output-streaming" ||
-    state === "output-error" ||
-    state === "output-denied"
-  ) {
+  if (state === "output-available" || state === "output-error" || state === "output-denied") {
     return true;
   }
   return part.output !== undefined;
@@ -281,6 +276,96 @@ const isWorkingMemoryTool = (part: ToolLikePart): boolean => {
   return WORKING_MEMORY_TOOL_NAMES.has(toolName);
 };
 
+const isWorkingMemoryToolPart = (part: UIMessagePart<any, any>): boolean => {
+  if (typeof part.type !== "string") {
+    return false;
+  }
+  if (!part.type.startsWith("tool-")) {
+    return false;
+  }
+  const toolName = toolNameFromType(part.type);
+  if (!toolName) {
+    return false;
+  }
+  return WORKING_MEMORY_TOOL_NAMES.has(toolName);
+};
+
+const isEmptyTextPart = (part: UIMessagePart<any, any>): boolean => {
+  if (part.type !== "text") {
+    return false;
+  }
+  const text = typeof (part as any).text === "string" ? (part as any).text : "";
+  return text.trim().length === 0;
+};
+
+const isPrunableToolPart = (part: UIMessagePart<any, any>): boolean => {
+  if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
+    return false;
+  }
+  const hasPendingState = (part as any).state === "input-available";
+  const hasResult =
+    (part as any).state === "output-available" || (part as any).output !== undefined;
+  if (hasPendingState || hasResult) {
+    return false;
+  }
+  return (part as any).input == null;
+};
+
+const isPrunablePartBeforeWorkingMemory = (part: UIMessagePart<any, any>): boolean => {
+  if (part.type === "step-start") {
+    return true;
+  }
+  if (part.type === "file" && !isObject(part as any)) {
+    return true;
+  }
+  if (part.type === "file" && !(part as any).url) {
+    return true;
+  }
+  if (isEmptyTextPart(part)) {
+    return true;
+  }
+  if (isWorkingMemoryToolPart(part)) {
+    return true;
+  }
+  if (isPrunableToolPart(part)) {
+    return true;
+  }
+  return false;
+};
+
+const shouldDropEmptyReasoningBeforeWorkingMemory = (
+  parts: UIMessagePart<any, any>[],
+  index: number,
+): boolean => {
+  const part = parts[index];
+  if (part.type !== "reasoning") {
+    return false;
+  }
+
+  const text = typeof (part as any).text === "string" ? (part as any).text : "";
+  if (text.trim().length > 0) {
+    return false;
+  }
+
+  for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) {
+    const next = parts[nextIndex];
+    if (isPrunablePartBeforeWorkingMemory(next)) {
+      if (isWorkingMemoryToolPart(next)) {
+        return true;
+      }
+      continue;
+    }
+
+    if (isWorkingMemoryToolPart(next)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  return false;
+};
+
 const normalizeToolOutputPayload = (output: unknown): unknown => {
   if (Array.isArray(output)) {
     return output.map((item) => normalizeToolOutputPayload(item));
@@ -356,58 +441,15 @@ export const sanitizeMessagesForModel = (
   return addStepStartsBetweenToolRuns(filtered);
 };
 
-const findNextContentPart = (
-  parts: UIMessagePart<any, any>[],
-  startIndex: number,
-): { kind: "working-memory" } | { kind: "part"; part: UIMessagePart<any, any> } | undefined => {
-  for (let index = startIndex; index < parts.length; index += 1) {
-    const candidate = parts[index];
-    if (!candidate) {
-      continue;
-    }
-    if (candidate.type === "step-start") {
-      continue;
-    }
-    if (isToolLikePart(candidate) && isWorkingMemoryTool(candidate as ToolLikePart)) {
-      return { kind: "working-memory" };
-    }
-    const normalized = normalizeGenericPart(candidate);
-    if (!normalized) {
-      continue;
-    }
-    if (isPrunableEmptyToolRun(normalized)) {
-      continue;
-    }
-    return { kind: "part", part: normalized };
-  }
-  return undefined;
-};
-
 export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => {
   const sanitizedParts: UIMessagePart<any, any>[] = [];
 
   for (let index = 0; index < message.parts.length; index += 1) {
     const part = message.parts[index];
-    if (part?.type === "reasoning") {
-      const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined;
-      const content =
-        typeof (part as any).content === "string" ? (part as any).content.trim() : undefined;
-      const explicitId =
-        typeof (part as any).id === "string"
-          ? (part as any).id.trim()
-          : typeof (part as any).reasoningId === "string"
-            ? (part as any).reasoningId.trim()
-            : undefined;
-      const providerMetadata = (part as any).providerMetadata;
-      const metadataReasoningId =
-        isObject(providerMetadata) ? extractReasoningIdFromMetadata(providerMetadata) : undefined;
-      const id = explicitId || metadataReasoningId;
-      const isEmptyReasoning = !text && !content && !id;
-      const nextContent = findNextContentPart(message.parts, index + 1);
-      if (isEmptyReasoning && nextContent?.kind === "working-memory") {
-        continue;
-      }
+    if (shouldDropEmptyReasoningBeforeWorkingMemory(message.parts, index)) {
+      continue;
     }
+
     const normalized = normalizeGenericPart(part);
     if (!normalized) {
       continue;
@@ -515,27 +557,7 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] =
       return true;
     });
 
-    let pruned = collapseRedundantStepStarts(parts);
-    const hasTextContent = pruned.some(
-      (part) =>
-        part.type === "text" &&
-        typeof (part as any).text === "string" &&
-        (part as any).text.trim().length > 0,
-    );
-    const hasToolPart = pruned.some((part) => isToolLikePart(part));
-    const hasNonReasoningPart = pruned.some((part) => part.type !== "reasoning");
-    const shouldDropEmptyReasoning =
-      (!hasTextContent && hasToolPart) || (mutated && !hasNonReasoningPart);
-
-    if (shouldDropEmptyReasoning) {
-      pruned = pruned.filter((part) => {
-        if (part.type !== "reasoning") {
-          return true;
-        }
-        const text = typeof (part as any).text === "string" ? (part as any).text.trim() : "";
-        return text.length > 0;
-      });
-    }
+    const pruned = collapseRedundantStepStarts(parts);
     if (pruned.length === 0) {
       continue;
     }
@@ -598,8 +620,13 @@ const addStepStartsBetweenToolRuns = (messages: UIMessage[]): UIMessage[] => {
 const pruneEmptyToolRuns = (parts: UIMessagePart<any, any>[]): UIMessagePart<any, any>[] => {
   const cleaned: UIMessagePart<any, any>[] = [];
   for (const part of parts) {
-    if (isPrunableEmptyToolRun(part)) {
-      continue;
+    if (typeof part.type === "string" && part.type.startsWith("tool-")) {
+      const hasPendingState = (part as any).state === "input-available";
+      const hasResult =
+        (part as any).state === "output-available" || (part as any).output !== undefined;
+      if (!hasPendingState && !hasResult && (part as any).input == null) {
+        continue;
+      }
     }
 
     cleaned.push(part);
@@ -607,19 +634,6 @@ const pruneEmptyToolRuns = (parts: UIMessagePart<any, any>[]): UIMessagePart<any
   return cleaned;
 };
 
-const isPrunableEmptyToolRun = (part: UIMessagePart<any, any>): boolean => {
-  if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
-    return false;
-  }
-  const state = (part as any).state;
-  const hasPendingState = state === "input-available" || state === "input-streaming";
-  const hasResult = hasToolOutput(part as ToolLikePart);
-  if (!hasPendingState && !hasResult && (part as any).input == null) {
-    return true;
-  }
-  return false;
-};
-
 const removeProviderExecutedToolsWithoutReasoning = (
   parts: UIMessagePart<any, any>[],
 ): UIMessagePart<any, any>[] => {

From 68de6138d3278b82a166de2f95a117f80681e91a Mon Sep 17 00:00:00 2001
From: "chris@rhythmic.com" <chris@gorhythmic.com>
Date: Thu, 5 Feb 2026 11:55:24 -0600
Subject: [PATCH 11/14] fix(core): preserve tool states and prune orphaned
 reasoning

---
 .../core/src/agent/message-normalizer.spec.ts |  48 --------
 packages/core/src/agent/message-normalizer.ts | 109 +++++++++++++++---
 2 files changed, 96 insertions(+), 61 deletions(-)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 86253d93e..741a063f5 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -643,52 +643,4 @@ describe("message-normalizer", () => {
     expect(sanitized[0].parts).toHaveLength(3);
     expect(sanitized[0].parts[1]).toEqual({ type: "step-start" });
   });
-
-  it("drops empty reasoning that precedes working-memory tool calls", () => {
-    const message = baseMessage([
-      {
-        type: "reasoning",
-        text: "",
-        providerMetadata: { openai: { itemId: "rs_openai" } },
-      } as any,
-      {
-        type: "tool-update_working_memory",
-        toolCallId: "call-1",
-        state: "output-available",
-        output: { content: "secret" },
-      } as any,
-      { type: "text", text: "visible" } as any,
-    ]);
-
-    const sanitized = sanitizeMessageForModel(message);
-    expect(sanitized).not.toBeNull();
-    const parts = (sanitized as UIMessage).parts;
-    expect(parts.some((part: any) => part.type === "reasoning")).toBe(false);
-    expect(parts.some((part: any) => part.type === "text")).toBe(true);
-  });
-
-  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
-    const message = baseMessage([
-      {
-        type: "reasoning",
-        text: "",
-        providerMetadata: { openai: { itemId: "rs_openai" } },
-      } as any,
-      { type: "text", text: "   " } as any,
-      { type: "step-start" } as any,
-      {
-        type: "tool-update_working_memory",
-        toolCallId: "call-2",
-        state: "input-available",
-        input: { content: "secret" },
-      } as any,
-      { type: "text", text: "visible" } as any,
-    ]);
-
-    const sanitized = sanitizeMessageForModel(message);
-    expect(sanitized).not.toBeNull();
-    const parts = (sanitized as UIMessage).parts;
-    expect(parts.some((part: any) => part.type === "reasoning")).toBe(false);
-    expect(parts.some((part: any) => part.type === "text")).toBe(true);
-  });
 });
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index a84843c0c..18b488112 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -261,12 +261,37 @@ const isToolLikePart = (part: UIMessagePart<any, any>): part is ToolLikePart =>
 
 const hasToolOutput = (part: ToolLikePart): boolean => {
   const state = typeof part.state === "string" ? part.state : undefined;
-  if (state === "output-available" || state === "output-error" || state === "output-denied") {
+  if (
+    state === "output-available" ||
+    state === "output-error" ||
+    state === "output-denied" ||
+    state === "output-streaming"
+  ) {
     return true;
   }
   return part.output !== undefined;
 };
 
+const isToolInputState = (state: string | undefined): boolean =>
+  state === "input-available" ||
+  state === "input-streaming" ||
+  state === "approval-requested" ||
+  state === "approval-responded";
+
+const isToolOutputState = (state: string | undefined): boolean =>
+  state === "output-available" ||
+  state === "output-error" ||
+  state === "output-denied" ||
+  state === "output-streaming";
+
+const hasToolInput = (part: ToolLikePart): boolean => {
+  const state = typeof part.state === "string" ? part.state : undefined;
+  if (isToolInputState(state)) {
+    return true;
+  }
+  return part.input !== undefined;
+};
+
 const isApprovalResponded = (part: ToolLikePart): boolean =>
   Boolean((part as any).approval && (part as any).approval.approved != null);
 
@@ -298,14 +323,22 @@ const isEmptyTextPart = (part: UIMessagePart<any, any>): boolean => {
   return text.trim().length === 0;
 };
 
+const isEmptyReasoningPart = (part: UIMessagePart<any, any>): boolean => {
+  if (part.type !== "reasoning") {
+    return false;
+  }
+  const text = typeof (part as any).text === "string" ? (part as any).text : "";
+  return text.trim().length === 0;
+};
+
 const isPrunableToolPart = (part: UIMessagePart<any, any>): boolean => {
   if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
     return false;
   }
-  const hasPendingState = (part as any).state === "input-available";
-  const hasResult =
-    (part as any).state === "output-available" || (part as any).output !== undefined;
-  if (hasPendingState || hasResult) {
+  const state = typeof (part as any).state === "string" ? (part as any).state : undefined;
+  const hasPendingState = isToolInputState(state);
+  const hasResult = isToolOutputState(state) || (part as any).output !== undefined;
+  if (hasPendingState || hasResult || (part as any).input !== undefined) {
     return false;
   }
   return (part as any).input == null;
@@ -342,8 +375,7 @@ const shouldDropEmptyReasoningBeforeWorkingMemory = (
     return false;
   }
 
-  const text = typeof (part as any).text === "string" ? (part as any).text : "";
-  if (text.trim().length > 0) {
+  if (!isEmptyReasoningPart(part)) {
     return false;
   }
 
@@ -366,6 +398,51 @@ const shouldDropEmptyReasoningBeforeWorkingMemory = (
   return false;
 };
 
+const removeEmptyReasoningWithOnlyToolOutputs = (
+  parts: UIMessagePart<any, any>[],
+): UIMessagePart<any, any>[] => {
+  const hasText = parts.some(
+    (part) =>
+      part.type === "text" &&
+      typeof (part as any).text === "string" &&
+      (part as any).text.trim().length > 0,
+  );
+  if (hasText) {
+    return parts;
+  }
+
+  const toolParts = parts.filter((part) => isToolLikePart(part)) as ToolLikePart[];
+  if (toolParts.length === 0) {
+    return parts;
+  }
+
+  const hasOutput = toolParts.some((part) => hasToolOutput(part));
+  const hasInput = toolParts.some((part) => hasToolInput(part));
+  if (!hasOutput || hasInput) {
+    return parts;
+  }
+
+  return parts.filter((part) => !isEmptyReasoningPart(part));
+};
+
+const dropOrphanedEmptyReasoning = (
+  parts: UIMessagePart<any, any>[],
+): UIMessagePart<any, any>[] => {
+  const nonReasoning = parts.filter((part) => part.type !== "reasoning");
+  if (nonReasoning.length > 0) {
+    return parts;
+  }
+
+  const hasNonEmptyReasoning = parts.some(
+    (part) => part.type === "reasoning" && !isEmptyReasoningPart(part),
+  );
+  if (hasNonEmptyReasoning) {
+    return parts;
+  }
+
+  return [];
+};
+
 const normalizeToolOutputPayload = (output: unknown): unknown => {
   if (Array.isArray(output)) {
     return output.map((item) => normalizeToolOutputPayload(item));
@@ -460,8 +537,9 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null =>
   const pruned = collapseRedundantStepStarts(pruneEmptyToolRuns(sanitizedParts));
   const withoutDanglingTools = removeProviderExecutedToolsWithoutReasoning(pruned);
   const normalizedParts = stripReasoningLinkedProviderMetadata(withoutDanglingTools);
+  const trimmedReasoning = removeEmptyReasoningWithOnlyToolOutputs(normalizedParts);
 
-  const effectiveParts = normalizedParts.filter((part) => {
+  const effectiveParts = trimmedReasoning.filter((part) => {
     if (part.type === "text") {
       return typeof (part as any).text === "string" && (part as any).text.trim().length > 0;
     }
@@ -562,12 +640,17 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] =
       continue;
     }
 
-    if (!mutated && pruned.length === message.parts.length) {
+    const withoutOrphanedReasoning = mutated ? dropOrphanedEmptyReasoning(pruned) : pruned;
+    if (withoutOrphanedReasoning.length === 0) {
+      continue;
+    }
+
+    if (!mutated && withoutOrphanedReasoning.length === message.parts.length) {
       filtered.push(message);
     } else {
       filtered.push({
         ...message,
-        parts: pruned,
+        parts: withoutOrphanedReasoning,
       });
     }
   }
@@ -621,9 +704,9 @@ const pruneEmptyToolRuns = (parts: UIMessagePart<any, any>[]): UIMessagePart<any
   const cleaned: UIMessagePart<any, any>[] = [];
   for (const part of parts) {
     if (typeof part.type === "string" && part.type.startsWith("tool-")) {
-      const hasPendingState = (part as any).state === "input-available";
-      const hasResult =
-        (part as any).state === "output-available" || (part as any).output !== undefined;
+      const state = typeof (part as any).state === "string" ? (part as any).state : undefined;
+      const hasPendingState = isToolInputState(state);
+      const hasResult = isToolOutputState(state) || (part as any).output !== undefined;
       if (!hasPendingState && !hasResult && (part as any).input == null) {
         continue;
       }

From cb65faa0a7e9352ba7ce971f1a67cc8fb3437923 Mon Sep 17 00:00:00 2001
From: "chris@rhythmic.com" <chris@gorhythmic.com>
Date: Thu, 5 Feb 2026 14:57:23 -0600
Subject: [PATCH 12/14] fix(core): drop standalone OpenAI reasoning items when
 they are the only assistant part

---
 .../core/src/agent/message-normalizer.spec.ts | 36 +++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 741a063f5..28e027f87 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -523,6 +523,42 @@ describe("message-normalizer", () => {
     expect(sanitized[1].role).toBe("user");
   });
 
+  it("drops standalone OpenAI reasoning items when they are the only assistant part", () => {
+    const messages: UIMessage[] = [
+      baseMessage([
+        {
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_only" } },
+        } as any,
+      ]),
+      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+    ];
+
+    const sanitized = sanitizeMessagesForModel(messages);
+
+    expect(sanitized).toHaveLength(1);
+    expect(sanitized[0].role).toBe("user");
+  });
+
+  it("drops standalone OpenAI reasoning items even when reasoning text exists", () => {
+    const messages: UIMessage[] = [
+      baseMessage([
+        {
+          type: "reasoning",
+          text: "Thinking...",
+          providerMetadata: { openai: { itemId: "rs_only_text" } },
+        } as any,
+      ]),
+      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+    ];
+
+    const sanitized = sanitizeMessagesForModel(messages);
+
+    expect(sanitized).toHaveLength(1);
+    expect(sanitized[0].role).toBe("user");
+  });
+
   it("drops empty reasoning that precedes working-memory tool calls", () => {
     const message = baseMessage([
       {

From c1c465f50a58d0ac98bd9680952b3897f7ca3374 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Thu, 5 Feb 2026 18:06:51 -0600
Subject: [PATCH 13/14] fix(core): implement
 stripDanglingOpenAIReasoningFromModelMessages to clean up reasoning items

---
 packages/core/src/agent/agent.ts              |   3 +
 .../core/src/agent/message-normalizer.spec.ts | 203 +++++------
 packages/core/src/agent/message-normalizer.ts | 339 +++++++++---------
 .../agent/model-message-normalizer.spec.ts    | 114 ++++++
 .../src/agent/model-message-normalizer.ts     | 112 ++++++
 .../src/memory/manager/memory-manager.spec.ts |  42 ---
 6 files changed, 483 insertions(+), 330 deletions(-)
 create mode 100644 packages/core/src/agent/model-message-normalizer.spec.ts
 create mode 100644 packages/core/src/agent/model-message-normalizer.ts

diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index f54e43aae..37e74bdf2 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -105,6 +105,7 @@ import {
   enqueueEvalScoring as enqueueEvalScoringHelper,
 } from "./eval";
 import type { AgentHooks, OnToolEndHookResult } from "./hooks";
+import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer";
 import { AgentTraceContext, addModelAttributesToSpan } from "./open-telemetry/trace-context";
 import type {
   BaseMessage,
@@ -3086,6 +3087,8 @@ export class Agent {
       }
     }
 
+    messages = stripDanglingOpenAIReasoningFromModelMessages(messages);
+
     // Calculate maxSteps (use provided option or calculate based on subagents)
     const maxSteps = options?.maxSteps ?? this.calculateMaxSteps();
 
diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts
index 28e027f87..67c1a2c7c 100644
--- a/packages/core/src/agent/message-normalizer.spec.ts
+++ b/packages/core/src/agent/message-normalizer.spec.ts
@@ -435,122 +435,127 @@ describe("message-normalizer", () => {
     expect(toolPart.callProviderMetadata).toEqual({ openai: { itemId: "fc_openai" } });
   });
 
-  it("sanitizes collections while preserving message ordering", () => {
+  it("drops OpenAI reasoning when no following item exists", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
-          type: "tool-update_working_memory",
-          toolCallId: "tool-1",
-          state: "input-available",
-          input: { content: "secret" },
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_only" } },
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "visible" } as any]),
     ];
 
-    const sanitized = sanitizeMessagesForModel(messages);
+    const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });
 
-    expect(sanitized).toHaveLength(1);
-    expect(sanitized[0].parts[0]).toEqual({ type: "text", text: "visible" });
+    expect(sanitized).toHaveLength(0);
   });
 
-  it("filters incomplete tool calls when preparing model messages", () => {
+  it("drops OpenAI reasoning when the next item lacks an OpenAI itemId", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
-          type: "tool-search",
-          toolCallId: "call-123",
-          state: "input-available",
-          input: { query: "hello" },
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_no_follow" } },
+        } as any,
+        {
+          type: "text",
+          text: "still keep this text",
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "follow up" } as any], "user"),
     ];
 
-    const sanitized = sanitizeMessagesForModel(messages);
+    const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });
 
     expect(sanitized).toHaveLength(1);
-    expect(sanitized[0].role).toBe("user");
+    const parts = sanitized[0].parts;
+    expect(parts).toHaveLength(1);
+    expect(parts[0]).toEqual({ type: "text", text: "still keep this text" });
   });
 
-  it("drops orphaned empty reasoning when incomplete tool calls are pruned", () => {
+  it("preserves OpenAI tool metadata when reasoning exists in another message", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
           type: "reasoning",
           text: "",
-          providerMetadata: { openai: { itemId: "rs_123" } },
+          providerMetadata: { openai: { itemId: "rs_cross" } },
         } as any,
+      ]),
+      baseMessage([
         {
           type: "tool-search",
-          toolCallId: "call-123",
+          toolCallId: "call-cross",
           state: "input-available",
           input: { query: "hello" },
+          callProviderMetadata: { openai: { itemId: "fc_cross" } },
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
     ];
 
-    const sanitized = sanitizeMessagesForModel(messages);
-
+    const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });
     expect(sanitized).toHaveLength(1);
-    expect(sanitized[0].role).toBe("user");
+    const toolPart = sanitized[0].parts.find(
+      (part: any) => typeof part.type === "string" && part.type.startsWith("tool-"),
+    ) as any;
+    expect(toolPart.callProviderMetadata).toEqual({ openai: { itemId: "fc_cross" } });
   });
 
-  it("drops empty reasoning when assistant message only contains tool output", () => {
+  it("preserves OpenAI text metadata when reasoning exists in another message", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
           type: "reasoning",
           text: "",
-          providerMetadata: { openai: { itemId: "rs_123" } },
+          providerMetadata: { openai: { itemId: "rs_cross_text" } },
         } as any,
+      ]),
+      baseMessage([
         {
-          type: "tool-search",
-          toolCallId: "call-456",
-          state: "output-available",
-          output: { results: [] },
+          type: "text",
+          text: "final answer",
+          providerMetadata: { openai: { itemId: "msg_cross" } },
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
     ];
 
-    const sanitized = sanitizeMessagesForModel(messages);
-
-    expect(sanitized).toHaveLength(2);
-    expect(sanitized[0].role).toBe("assistant");
-    expect(sanitized[0].parts.some((part) => part.type === "reasoning")).toBe(false);
-    expect(sanitized[1].role).toBe("user");
+    const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });
+    expect(sanitized).toHaveLength(1);
+    const textPart = sanitized[0].parts.find((part: any) => part.type === "text") as any;
+    expect(textPart.providerMetadata).toEqual({ openai: { itemId: "msg_cross" } });
   });
 
-  it("drops standalone OpenAI reasoning items when they are the only assistant part", () => {
+  it("sanitizes collections while preserving message ordering", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
-          type: "reasoning",
-          text: "",
-          providerMetadata: { openai: { itemId: "rs_only" } },
+          type: "tool-update_working_memory",
+          toolCallId: "tool-1",
+          state: "input-available",
+          input: { content: "secret" },
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+      baseMessage([{ type: "text", text: "visible" } as any]),
     ];
 
     const sanitized = sanitizeMessagesForModel(messages);
 
     expect(sanitized).toHaveLength(1);
-    expect(sanitized[0].role).toBe("user");
+    expect(sanitized[0].parts[0]).toEqual({ type: "text", text: "visible" });
   });
 
-  it("drops standalone OpenAI reasoning items even when reasoning text exists", () => {
+  it("filters incomplete tool calls when preparing model messages", () => {
     const messages: UIMessage[] = [
       baseMessage([
         {
-          type: "reasoning",
-          text: "Thinking...",
-          providerMetadata: { openai: { itemId: "rs_only_text" } },
+          type: "tool-search",
+          toolCallId: "call-123",
+          state: "input-available",
+          input: { query: "hello" },
         } as any,
       ]),
-      baseMessage([{ type: "text", text: "next turn" } as any], "user"),
+      baseMessage([{ type: "text", text: "follow up" } as any], "user"),
     ];
 
     const sanitized = sanitizeMessagesForModel(messages);
@@ -559,79 +564,39 @@ describe("message-normalizer", () => {
     expect(sanitized[0].role).toBe("user");
   });
 
-  it("drops empty reasoning that precedes working-memory tool calls", () => {
-    const message = baseMessage([
-      {
-        type: "reasoning",
-        text: "Planning next steps.",
-        providerMetadata: { openai: { itemId: "rs_text" } },
-      } as any,
-      {
-        type: "tool-search",
-        toolCallId: "call-1",
-        state: "output-available",
-        output: { result: true },
-      } as any,
-      {
-        type: "reasoning",
-        text: "",
-        providerMetadata: { openai: { itemId: "rs_empty" } },
-      } as any,
-      {
-        type: "tool-update_working_memory",
-        toolCallId: "call-2",
-        state: "output-available",
-        output: { type: "text", value: "ok" },
-      } as any,
-      { type: "step-start" } as any,
-      { type: "text", text: "final answer" } as any,
-    ]);
-
-    const sanitized = sanitizeMessageForModel(message);
-    expect(sanitized).not.toBeNull();
-    const parts = (sanitized as UIMessage).parts;
-    expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false);
-    expect(
-      parts.some(
-        (part: any) =>
-          part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty",
-      ),
-    ).toBe(false);
-    expect(parts.some((part: any) => part.type === "reasoning")).toBe(true);
-    expect(parts.some((part: any) => part.type === "text")).toBe(true);
-  });
+  it("keeps tool calls when OpenAI reasoning items exist in the conversation", () => {
+    const messages: UIMessage[] = [
+      baseMessage([
+        {
+          type: "reasoning",
+          text: "",
+          providerMetadata: { openai: { itemId: "rs_123" } },
+        } as any,
+      ]),
+      baseMessage([
+        {
+          type: "tool-search",
+          toolCallId: "call-123",
+          state: "input-available",
+          input: { query: "hello" },
+          callProviderMetadata: { openai: { itemId: "fc_123" } },
+        } as any,
+      ]),
+      baseMessage([{ type: "text", text: "follow up" } as any], "user"),
+    ];
 
-  it("drops empty reasoning when prunable parts sit before working-memory tools", () => {
-    const message = baseMessage([
-      {
-        type: "reasoning",
-        text: "",
-        providerMetadata: { openai: { itemId: "rs_empty" } },
-      } as any,
-      {
-        type: "tool-search",
-        toolCallId: "call-ghost",
-      } as any,
-      {
-        type: "tool-update_working_memory",
-        toolCallId: "call-mem",
-        state: "output-available",
-        output: { type: "text", value: "ok" },
-      } as any,
-      { type: "text", text: "final answer" } as any,
-    ]);
+    const sanitized = sanitizeMessagesForModel(messages);
 
-    const sanitized = sanitizeMessageForModel(message);
-    expect(sanitized).not.toBeNull();
-    const parts = (sanitized as UIMessage).parts;
-    expect(
-      parts.some(
-        (part: any) =>
-          part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty",
-      ),
-    ).toBe(false);
-    expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false);
-    expect(parts.some((part: any) => part.type === "text")).toBe(true);
+    expect(sanitized).toHaveLength(2);
+    const toolParts = sanitized[0].parts.filter(
+      (part: any) => typeof part.type === "string" && part.type.startsWith("tool-"),
+    );
+    expect(toolParts).toHaveLength(1);
+    expect(toolParts[0]).toMatchObject({
+      type: "tool-search",
+      toolCallId: "call-123",
+      callProviderMetadata: { openai: { itemId: "fc_123" } },
+    });
   });
 
   it("preserves approval responses on the last assistant message", () => {
diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 18b488112..9f579d8cf 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -284,14 +284,6 @@ const isToolOutputState = (state: string | undefined): boolean =>
   state === "output-denied" ||
   state === "output-streaming";
 
-const hasToolInput = (part: ToolLikePart): boolean => {
-  const state = typeof part.state === "string" ? part.state : undefined;
-  if (isToolInputState(state)) {
-    return true;
-  }
-  return part.input !== undefined;
-};
-
 const isApprovalResponded = (part: ToolLikePart): boolean =>
   Boolean((part as any).approval && (part as any).approval.approved != null);
 
@@ -301,148 +293,6 @@ const isWorkingMemoryTool = (part: ToolLikePart): boolean => {
   return WORKING_MEMORY_TOOL_NAMES.has(toolName);
 };
 
-const isWorkingMemoryToolPart = (part: UIMessagePart<any, any>): boolean => {
-  if (typeof part.type !== "string") {
-    return false;
-  }
-  if (!part.type.startsWith("tool-")) {
-    return false;
-  }
-  const toolName = toolNameFromType(part.type);
-  if (!toolName) {
-    return false;
-  }
-  return WORKING_MEMORY_TOOL_NAMES.has(toolName);
-};
-
-const isEmptyTextPart = (part: UIMessagePart<any, any>): boolean => {
-  if (part.type !== "text") {
-    return false;
-  }
-  const text = typeof (part as any).text === "string" ? (part as any).text : "";
-  return text.trim().length === 0;
-};
-
-const isEmptyReasoningPart = (part: UIMessagePart<any, any>): boolean => {
-  if (part.type !== "reasoning") {
-    return false;
-  }
-  const text = typeof (part as any).text === "string" ? (part as any).text : "";
-  return text.trim().length === 0;
-};
-
-const isPrunableToolPart = (part: UIMessagePart<any, any>): boolean => {
-  if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
-    return false;
-  }
-  const state = typeof (part as any).state === "string" ? (part as any).state : undefined;
-  const hasPendingState = isToolInputState(state);
-  const hasResult = isToolOutputState(state) || (part as any).output !== undefined;
-  if (hasPendingState || hasResult || (part as any).input !== undefined) {
-    return false;
-  }
-  return (part as any).input == null;
-};
-
-const isPrunablePartBeforeWorkingMemory = (part: UIMessagePart<any, any>): boolean => {
-  if (part.type === "step-start") {
-    return true;
-  }
-  if (part.type === "file" && !isObject(part as any)) {
-    return true;
-  }
-  if (part.type === "file" && !(part as any).url) {
-    return true;
-  }
-  if (isEmptyTextPart(part)) {
-    return true;
-  }
-  if (isWorkingMemoryToolPart(part)) {
-    return true;
-  }
-  if (isPrunableToolPart(part)) {
-    return true;
-  }
-  return false;
-};
-
-const shouldDropEmptyReasoningBeforeWorkingMemory = (
-  parts: UIMessagePart<any, any>[],
-  index: number,
-): boolean => {
-  const part = parts[index];
-  if (part.type !== "reasoning") {
-    return false;
-  }
-
-  if (!isEmptyReasoningPart(part)) {
-    return false;
-  }
-
-  for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) {
-    const next = parts[nextIndex];
-    if (isPrunablePartBeforeWorkingMemory(next)) {
-      if (isWorkingMemoryToolPart(next)) {
-        return true;
-      }
-      continue;
-    }
-
-    if (isWorkingMemoryToolPart(next)) {
-      return true;
-    }
-
-    return false;
-  }
-
-  return false;
-};
-
-const removeEmptyReasoningWithOnlyToolOutputs = (
-  parts: UIMessagePart<any, any>[],
-): UIMessagePart<any, any>[] => {
-  const hasText = parts.some(
-    (part) =>
-      part.type === "text" &&
-      typeof (part as any).text === "string" &&
-      (part as any).text.trim().length > 0,
-  );
-  if (hasText) {
-    return parts;
-  }
-
-  const toolParts = parts.filter((part) => isToolLikePart(part)) as ToolLikePart[];
-  if (toolParts.length === 0) {
-    return parts;
-  }
-
-  const hasOutput = toolParts.some((part) => hasToolOutput(part));
-  const hasInput = toolParts.some((part) => hasToolInput(part));
-  if (!hasOutput || hasInput) {
-    return parts;
-  }
-
-  return parts.filter((part) => !isEmptyReasoningPart(part));
-};
-
-const dropOrphanedEmptyReasoning = (
-  parts: UIMessagePart<any, any>[],
-): UIMessagePart<any, any>[] => {
-  const nonReasoning = parts.filter((part) => part.type !== "reasoning");
-  if (nonReasoning.length > 0) {
-    return parts;
-  }
-
-  const hasNonEmptyReasoning = parts.some(
-    (part) => part.type === "reasoning" && !isEmptyReasoningPart(part),
-  );
-  if (hasNonEmptyReasoning) {
-    return parts;
-  }
-
-  return [];
-};
-
 const normalizeToolOutputPayload = (output: unknown): unknown => {
   if (Array.isArray(output)) {
     return output.map((item) => normalizeToolOutputPayload(item));
@@ -501,6 +351,157 @@ const normalizeToolPart = (part: ToolLikePart): UIMessagePart<any, any> | null =
   return normalized as UIMessagePart<any, any>;
 };
 
+const hasOpenAIReasoningInMessages = (messages: UIMessage[]): boolean =>
+  messages.some(
+    (message) => message.role === "assistant" && hasOpenAIReasoningContext(message.parts),
+  );
+
+const countToolLikeParts = (messages: UIMessage[]): number =>
+  messages.reduce(
+    (count, message) => count + message.parts.filter((part) => isToolLikePart(part)).length,
+    0,
+  );
+
+const isOpenAIReasoningPart = (part: UIMessagePart<any, any>): boolean => {
+  if (part.type !== "reasoning") {
+    return false;
+  }
+
+  const reasoningId =
+    typeof (part as any).reasoningId === "string" ? (part as any).reasoningId.trim() : "";
+  if (reasoningId && isOpenAIReasoningId(reasoningId)) {
+    return true;
+  }
+
+  const providerMetadata = (part as any).providerMetadata;
+  if (isObject(providerMetadata)) {
+    const openai = providerMetadata.openai;
+    if (isObject(openai)) {
+      const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
+      if (itemId && isOpenAIReasoningId(itemId)) {
+        return true;
+      }
+      if (typeof openai.reasoning_trace_id === "string" && openai.reasoning_trace_id.trim()) {
+        return true;
+      }
+      if (isObject(openai.reasoning)) {
+        const id = typeof openai.reasoning.id === "string" ? openai.reasoning.id.trim() : "";
+        if (id && isOpenAIReasoningId(id)) {
+          return true;
+        }
+      }
+    }
+  }
+
+  return false;
+};
+
+const endsWithOpenAIReasoning = (parts: UIMessagePart<any, any>[]): boolean => {
+  for (let index = parts.length - 1; index >= 0; index -= 1) {
+    const part = parts[index];
+    if (part.type === "step-start") {
+      continue;
+    }
+    return isOpenAIReasoningPart(part);
+  }
+  return false;
+};
+
+const hasOpenAIItemId = (metadata: unknown): boolean => {
+  if (!isObject(metadata)) {
+    return false;
+  }
+  const openai = (metadata as { openai?: unknown }).openai;
+  if (!isObject(openai)) {
+    return false;
+  }
+  const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
+  return Boolean(itemId);
+};
+
+const hasOpenAIItemIdForPart = (part: UIMessagePart<any, any>): boolean => {
+  if (typeof part.type === "string" && part.type.startsWith("tool-")) {
+    if (hasOpenAIItemId((part as any).callProviderMetadata)) {
+      return true;
+    }
+  }
+  return hasOpenAIItemId((part as any).providerMetadata);
+};
+
+const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => {
+  const result: UIMessage[] = [];
+
+  for (const message of messages) {
+    if (message.role !== "assistant") {
+      result.push(message);
+      continue;
+    }
+
+    const parts: UIMessagePart<any, any>[] = [];
+    for (let index = 0; index < message.parts.length; index += 1) {
+      const part = message.parts[index];
+      if (!isOpenAIReasoningPart(part)) {
+        parts.push(part);
+        continue;
+      }
+
+      let next: UIMessagePart<any, any> | undefined;
+      for (let nextIndex = index + 1; nextIndex < message.parts.length; nextIndex += 1) {
+        const candidate = message.parts[nextIndex];
+        if (candidate.type === "step-start") {
+          continue;
+        }
+        next = candidate;
+        break;
+      }
+
+      if (!next) {
+        continue;
+      }
+      if (isOpenAIReasoningPart(next)) {
+        continue;
+      }
+      if (!hasOpenAIItemIdForPart(next)) {
+        continue;
+      }
+
+      parts.push(part);
+    }
+
+    if (parts.length === 0) {
+      continue;
+    }
+
+    result.push({
+      ...message,
+      parts,
+      ...(message.metadata ? { metadata: safeClone(message.metadata) } : {}),
+    });
+  }
+
+  return result;
+};
+
+const mergeTrailingReasoningAssistantMessages = (messages: UIMessage[]): UIMessage[] => {
+  const merged: UIMessage[] = [];
+
+  for (const message of messages) {
+    const last = merged.at(-1);
+    if (
+      last &&
+      last.role === "assistant" &&
+      message.role === "assistant" &&
+      endsWithOpenAIReasoning(last.parts)
+    ) {
+      last.parts = [...last.parts, ...message.parts];
+      continue;
+    }
+    merged.push(message);
+  }
+
+  return merged;
+};
+
 export const sanitizeMessagesForModel = (
   messages: UIMessage[],
   options: SanitizeMessagesOptions = {},
@@ -509,24 +510,30 @@ export const sanitizeMessagesForModel = (
     .map((message) => sanitizeMessageForModel(message))
     .filter((message): message is UIMessage => Boolean(message));
 
+  const merged = mergeTrailingReasoningAssistantMessages(sanitized);
   const shouldFilterIncomplete = options.filterIncompleteToolCalls !== false;
 
-  const filtered = shouldFilterIncomplete
-    ? filterIncompleteToolCallsForModel(sanitized)
-    : sanitized;
+  if (!shouldFilterIncomplete) {
+    return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged));
+  }
+
+  const filtered = filterIncompleteToolCallsForModel(merged);
+  const hasOpenAIReasoning = hasOpenAIReasoningInMessages(merged);
+  if (hasOpenAIReasoning) {
+    const sanitizedToolCount = countToolLikeParts(merged);
+    const filteredToolCount = countToolLikeParts(filtered);
+    if (filteredToolCount < sanitizedToolCount) {
+      return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged));
+    }
+  }
 
-  return addStepStartsBetweenToolRuns(filtered);
+  return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(filtered));
 };
 
 export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => {
   const sanitizedParts: UIMessagePart<any, any>[] = [];
 
-  for (let index = 0; index < message.parts.length; index += 1) {
-    const part = message.parts[index];
-    if (shouldDropEmptyReasoningBeforeWorkingMemory(message.parts, index)) {
-      continue;
-    }
-
+  for (const part of message.parts) {
     const normalized = normalizeGenericPart(part);
     if (!normalized) {
       continue;
@@ -537,9 +544,8 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null =>
   const pruned = collapseRedundantStepStarts(pruneEmptyToolRuns(sanitizedParts));
   const withoutDanglingTools = removeProviderExecutedToolsWithoutReasoning(pruned);
   const normalizedParts = stripReasoningLinkedProviderMetadata(withoutDanglingTools);
-  const trimmedReasoning = removeEmptyReasoningWithOnlyToolOutputs(normalizedParts);
 
-  const effectiveParts = trimmedReasoning.filter((part) => {
+  const effectiveParts = normalizedParts.filter((part) => {
     if (part.type === "text") {
       return typeof (part as any).text === "string" && (part as any).text.trim().length > 0;
     }
@@ -640,17 +646,12 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] =
       continue;
     }
 
-    const withoutOrphanedReasoning = mutated ? dropOrphanedEmptyReasoning(pruned) : pruned;
-    if (withoutOrphanedReasoning.length === 0) {
-      continue;
-    }
-
-    if (!mutated && withoutOrphanedReasoning.length === message.parts.length) {
+    if (!mutated && pruned.length === message.parts.length) {
       filtered.push(message);
     } else {
       filtered.push({
         ...message,
-        parts: withoutOrphanedReasoning,
+        parts: pruned,
       });
     }
   }
diff --git a/packages/core/src/agent/model-message-normalizer.spec.ts b/packages/core/src/agent/model-message-normalizer.spec.ts
new file mode 100644
index 000000000..12885fd7a
--- /dev/null
+++ b/packages/core/src/agent/model-message-normalizer.spec.ts
@@ -0,0 +1,114 @@
+import type { ModelMessage } from "@ai-sdk/provider-utils";
+import { describe, expect, it } from "vitest";
+
+import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer";
+
+describe("model-message-normalizer", () => {
+  it("drops dangling OpenAI reasoning items", () => {
+    const messages: ModelMessage[] = [
+      {
+        role: "assistant",
+        content: [
+          {
+            type: "reasoning",
+            text: "",
+            providerOptions: { openai: { itemId: "rs_only" } },
+          },
+        ],
+      } as ModelMessage,
+      {
+        role: "user",
+        content: "hello",
+      },
+    ];
+
+    const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages);
+
+    expect(sanitized).toHaveLength(1);
+    expect(sanitized[0].role).toBe("user");
+  });
+
+  it("drops reasoning when the next part lacks an OpenAI itemId", () => {
+    const messages: ModelMessage[] = [
+      {
+        role: "assistant",
+        content: [
+          {
+            type: "reasoning",
+            text: "",
+            providerOptions: { openai: { itemId: "rs_drop" } },
+          },
+          {
+            type: "text",
+            text: "keep this",
+          },
+        ],
+      } as ModelMessage,
+    ];
+
+    const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages);
+    const content = sanitized[0].content as any[];
+
+    expect(content).toHaveLength(1);
+    expect(content[0]).toEqual({ type: "text", text: "keep this" });
+  });
+
+  it("keeps reasoning when followed by an OpenAI itemId part", () => {
+    const messages: ModelMessage[] = [
+      {
+        role: "assistant",
+        content: [
+          {
+            type: "reasoning",
+            text: "",
+            providerOptions: { openai: { itemId: "rs_keep" } },
+          },
+          {
+            type: "text",
+            text: "paired",
+            providerOptions: { openai: { itemId: "msg_keep" } },
+          },
+        ],
+      } as ModelMessage,
+    ];
+
+    const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages);
+    const content = sanitized[0].content as any[];
+
+    expect(content).toHaveLength(2);
+    expect(content[0].type).toBe("reasoning");
+    expect(content[1].type).toBe("text");
+  });
+
+  it("drops earlier reasoning when another reasoning follows", () => {
+    const messages: ModelMessage[] = [
+      {
+        role: "assistant",
+        content: [
+          {
+            type: "reasoning",
+            text: "",
+            providerOptions: { openai: { itemId: "rs_first" } },
+          },
+          {
+            type: "reasoning",
+            text: "",
+            providerOptions: { openai: { itemId: "rs_second" } },
+          },
+          {
+            type: "text",
+            text: "paired",
+            providerOptions: { openai: { itemId: "msg_keep" } },
+          },
+        ],
+      } as ModelMessage,
+    ];
+
+    const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages);
+    const content = sanitized[0].content as any[];
+
+    expect(content).toHaveLength(2);
+    expect(content[0].providerOptions).toEqual({ openai: { itemId: "rs_second" } });
+    expect(content[1].type).toBe("text");
+  });
+});
diff --git a/packages/core/src/agent/model-message-normalizer.ts b/packages/core/src/agent/model-message-normalizer.ts
new file mode 100644
index 000000000..9e679fc88
--- /dev/null
+++ b/packages/core/src/agent/model-message-normalizer.ts
@@ -0,0 +1,112 @@
+import type { ModelMessage } from "@ai-sdk/provider-utils";
+
+const OPENAI_REASONING_ID_PREFIX = "rs_";
+
+const isObject = (value: unknown): value is Record<string, unknown> =>
+  typeof value === "object" && value !== null;
+
+const hasOpenAIItemId = (providerOptions: unknown): boolean => {
+  if (!isObject(providerOptions)) {
+    return false;
+  }
+
+  const openai = (providerOptions as { openai?: unknown }).openai;
+  if (!isObject(openai)) {
+    return false;
+  }
+
+  const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
+  return itemId.length > 0;
+};
+
+const isOpenAIReasoningId = (value: string): boolean =>
+  value.trim().startsWith(OPENAI_REASONING_ID_PREFIX);
+
+const isOpenAIReasoningPart = (part: unknown): boolean => {
+  if (!isObject(part)) {
+    return false;
+  }
+  if ((part as { type?: unknown }).type !== "reasoning") {
+    return false;
+  }
+
+  const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
+  if (hasOpenAIItemId(providerOptions)) {
+    const openai = (providerOptions as { openai?: unknown }).openai as { itemId?: unknown };
+    const itemId = typeof openai?.itemId === "string" ? openai.itemId.trim() : "";
+    if (itemId && isOpenAIReasoningId(itemId)) {
+      return true;
+    }
+  }
+
+  const reasoningId =
+    typeof (part as { id?: unknown }).id === "string" ? (part as any).id.trim() : "";
+  return Boolean(reasoningId && isOpenAIReasoningId(reasoningId));
+};
+
+const hasOpenAIItemIdForPart = (part: unknown): boolean => {
+  if (!isObject(part)) {
+    return false;
+  }
+  const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
+  return hasOpenAIItemId(providerOptions);
+};
+
+export const stripDanglingOpenAIReasoningFromModelMessages = (
+  messages: ModelMessage[],
+): ModelMessage[] => {
+  let changed = false;
+
+  const sanitized = messages
+    .map((message) => {
+      if (message.role !== "assistant" || !Array.isArray(message.content)) {
+        return message;
+      }
+
+      const parts = [];
+      const content = message.content as unknown[];
+
+      for (let index = 0; index < content.length; index += 1) {
+        const part = content[index];
+        if (!isOpenAIReasoningPart(part)) {
+          parts.push(part);
+          continue;
+        }
+
+        const next = content[index + 1];
+
+        if (!next) {
+          changed = true;
+          continue;
+        }
+        if (isOpenAIReasoningPart(next)) {
+          changed = true;
+          continue;
+        }
+        if (!hasOpenAIItemIdForPart(next)) {
+          changed = true;
+          continue;
+        }
+
+        parts.push(part);
+      }
+
+      if (parts.length === content.length) {
+        return message;
+      }
+
+      changed = true;
+
+      if (parts.length === 0) {
+        return null;
+      }
+
+      return {
+        ...message,
+        content: parts as ModelMessage["content"],
+      } satisfies ModelMessage;
+    })
+    .filter((message): message is ModelMessage => Boolean(message));
+
+  return changed ? sanitized : messages;
+};
diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts
index bfc633b08..3750e83d5 100644
--- a/packages/core/src/memory/manager/memory-manager.spec.ts
+++ b/packages/core/src/memory/manager/memory-manager.spec.ts
@@ -1,5 +1,4 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { sanitizeMessagesForModel } from "../../agent/message-normalizer";
 import type { OperationContext } from "../../agent/types";
 import { getGlobalLogger } from "../../logger";
 import { Memory } from "../../memory";
@@ -162,47 +161,6 @@ describe("MemoryManager", () => {
       expect(messages[1].id).toBe("msg-2");
     });
 
-    it("drops empty reasoning that precedes working-memory tool calls on reload", async () => {
-      const context = createMockOperationContext();
-
-      const assistantMessage = createTestUIMessage({
-        id: "assistant-1",
-        role: "assistant",
-        parts: [
-          {
-            type: "reasoning",
-            text: "",
-            providerMetadata: { openai: { itemId: "rs_123" } },
-          } as any,
-          {
-            type: "tool-search",
-            toolCallId: "call-123",
-            state: "input-available",
-            input: { query: "hello" },
-          } as any,
-          {
-            type: "tool-update_working_memory",
-            toolCallId: "call-999",
-            state: "output-available",
-            output: { type: "text", value: "ok" },
-          } as any,
-        ],
-      });
-
-      await manager.saveMessage(context, assistantMessage, "user-1", "conv-1");
-
-      const reloaded = await manager.getMessages(context, "user-1", "conv-1");
-      const nextTurn = {
-        id: "user-2",
-        role: "user",
-        parts: [{ type: "text", text: "next turn" }],
-      };
-
-      const sanitized = sanitizeMessagesForModel([...reloaded, nextTurn]);
-      expect(sanitized).toHaveLength(1);
-      expect(sanitized[0].role).toBe("user");
-    });
-
     it("should return empty array when memory is disabled", async () => {
       const disabledManager = new MemoryManager("agent-3", false);
 

From de0cf2d4ba4f1b1aded71d4ac4556ac266fc5871 Mon Sep 17 00:00:00 2001
From: chris <chris@gorhythmic.com>
Date: Thu, 5 Feb 2026 18:56:25 -0600
Subject: [PATCH 14/14] refactor(core): modularize OpenAI reasoning utilities
 and enhance message sanitization

---
 packages/core/src/agent/message-normalizer.ts | 87 +++++++------------
 .../src/agent/model-message-normalizer.ts     | 72 +++++----------
 .../core/src/agent/openai-reasoning-utils.ts  | 78 +++++++++++++++++
 3 files changed, 128 insertions(+), 109 deletions(-)
 create mode 100644 packages/core/src/agent/openai-reasoning-utils.ts

diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts
index 9f579d8cf..ea163abf8 100644
--- a/packages/core/src/agent/message-normalizer.ts
+++ b/packages/core/src/agent/message-normalizer.ts
@@ -1,13 +1,19 @@
+import { safeStringify } from "@voltagent/internal";
 import type { UIMessage, UIMessagePart } from "ai";
 
+import {
+  hasOpenAIItemIdForPart as hasOpenAIItemIdForPartBase,
+  isObject,
+  isOpenAIReasoningId,
+  stripDanglingOpenAIReasoningFromParts,
+} from "./openai-reasoning-utils";
+
 const WORKING_MEMORY_TOOL_NAMES = new Set([
   "update_working_memory",
   "get_working_memory",
   "clear_working_memory",
 ]);
 
-const OPENAI_REASONING_ID_PREFIX = "rs_";
-
 type ToolLikePart = UIMessagePart<any, any> & {
   toolCallId?: string;
   state?: string;
@@ -27,12 +33,6 @@ type SanitizeMessagesOptions = {
   filterIncompleteToolCalls?: boolean;
 };
 
-const isObject = (value: unknown): value is Record<string, unknown> =>
-  typeof value === "object" && value !== null;
-
-const isOpenAIReasoningId = (value: string): boolean =>
-  value.trim().startsWith(OPENAI_REASONING_ID_PREFIX);
-
 const safeClone = <T>(value: T): T => {
   if (!isObject(value) && !Array.isArray(value)) {
     return value;
@@ -47,7 +47,7 @@ const safeClone = <T>(value: T): T => {
   }
 
   try {
-    return JSON.parse(JSON.stringify(value)) as T;
+    return JSON.parse(safeStringify(value)) as T;
   } catch (_error) {
     if (Array.isArray(value)) {
       return value.slice() as T;
@@ -407,25 +407,13 @@ const endsWithOpenAIReasoning = (parts: UIMessagePart<any, any>[]): boolean => {
   return false;
 };
 
-const hasOpenAIItemId = (metadata: unknown): boolean => {
-  if (!isObject(metadata)) {
-    return false;
-  }
-  const openai = (metadata as { openai?: unknown }).openai;
-  if (!isObject(openai)) {
-    return false;
-  }
-  const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
-  return Boolean(itemId);
-};
-
 const hasOpenAIItemIdForPart = (part: UIMessagePart<any, any>): boolean => {
-  if (typeof part.type === "string" && part.type.startsWith("tool-")) {
-    if (hasOpenAIItemId((part as any).callProviderMetadata)) {
-      return true;
-    }
-  }
-  return hasOpenAIItemId((part as any).providerMetadata);
+  return hasOpenAIItemIdForPartBase(part, {
+    isToolPart: (candidate) =>
+      typeof (candidate as any).type === "string" && (candidate as any).type.startsWith("tool-"),
+    getCallProviderMetadata: (candidate) => (candidate as any).callProviderMetadata,
+    getProviderMetadata: (candidate) => (candidate as any).providerMetadata,
+  });
 };
 
 const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => {
@@ -437,36 +425,20 @@ const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => {
       continue;
     }
 
-    const parts: UIMessagePart<any, any>[] = [];
-    for (let index = 0; index < message.parts.length; index += 1) {
-      const part = message.parts[index];
-      if (!isOpenAIReasoningPart(part)) {
-        parts.push(part);
-        continue;
-      }
-
-      let next: UIMessagePart<any, any> | undefined;
-      for (let nextIndex = index + 1; nextIndex < message.parts.length; nextIndex += 1) {
-        const candidate = message.parts[nextIndex];
-        if (candidate.type === "step-start") {
-          continue;
+    const { parts } = stripDanglingOpenAIReasoningFromParts(message.parts, {
+      isReasoningPart: isOpenAIReasoningPart,
+      hasOpenAIItemIdForPart,
+      getNextPart: (parts, index) => {
+        for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) {
+          const candidate = parts[nextIndex];
+          if (candidate.type === "step-start") {
+            continue;
+          }
+          return candidate;
         }
-        next = candidate;
-        break;
-      }
-
-      if (!next) {
-        continue;
-      }
-      if (isOpenAIReasoningPart(next)) {
-        continue;
-      }
-      if (!hasOpenAIItemIdForPart(next)) {
-        continue;
-      }
-
-      parts.push(part);
-    }
+        return undefined;
+      },
+    });
 
     if (parts.length === 0) {
       continue;
@@ -496,7 +468,7 @@ const mergeTrailingReasoningAssistantMessages = (messages: UIMessage[]): UIMessa
       last.parts = [...last.parts, ...message.parts];
       continue;
     }
-    merged.push(message);
+    merged.push({ ...message, parts: [...message.parts] });
   }
 
   return merged;
@@ -523,6 +495,7 @@ export const sanitizeMessagesForModel = (
     const sanitizedToolCount = countToolLikeParts(merged);
     const filteredToolCount = countToolLikeParts(filtered);
     if (filteredToolCount < sanitizedToolCount) {
+      // Keep the merged set to avoid orphaning reasoning item references when tools were removed.
       return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged));
     }
   }
diff --git a/packages/core/src/agent/model-message-normalizer.ts b/packages/core/src/agent/model-message-normalizer.ts
index 9e679fc88..e3e5164e1 100644
--- a/packages/core/src/agent/model-message-normalizer.ts
+++ b/packages/core/src/agent/model-message-normalizer.ts
@@ -1,26 +1,12 @@
 import type { ModelMessage } from "@ai-sdk/provider-utils";
 
-const OPENAI_REASONING_ID_PREFIX = "rs_";
-
-const isObject = (value: unknown): value is Record<string, unknown> =>
-  typeof value === "object" && value !== null;
-
-const hasOpenAIItemId = (providerOptions: unknown): boolean => {
-  if (!isObject(providerOptions)) {
-    return false;
-  }
-
-  const openai = (providerOptions as { openai?: unknown }).openai;
-  if (!isObject(openai)) {
-    return false;
-  }
-
-  const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
-  return itemId.length > 0;
-};
-
-const isOpenAIReasoningId = (value: string): boolean =>
-  value.trim().startsWith(OPENAI_REASONING_ID_PREFIX);
+import {
+  hasOpenAIItemId,
+  hasOpenAIItemIdForPart as hasOpenAIItemIdForPartBase,
+  isObject,
+  isOpenAIReasoningId,
+  stripDanglingOpenAIReasoningFromParts,
+} from "./openai-reasoning-utils";
 
 const isOpenAIReasoningPart = (part: unknown): boolean => {
   if (!isObject(part)) {
@@ -48,8 +34,9 @@ const hasOpenAIItemIdForPart = (part: unknown): boolean => {
   if (!isObject(part)) {
     return false;
   }
-  const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
-  return hasOpenAIItemId(providerOptions);
+  return hasOpenAIItemIdForPartBase(part, {
+    getProviderMetadata: (value) => (value as { providerOptions?: unknown }).providerOptions,
+  });
 };
 
 export const stripDanglingOpenAIReasoningFromModelMessages = (
@@ -63,35 +50,14 @@ export const stripDanglingOpenAIReasoningFromModelMessages = (
         return message;
       }
 
-      const parts = [];
       const content = message.content as unknown[];
+      const { parts, changed: partsChanged } = stripDanglingOpenAIReasoningFromParts(content, {
+        isReasoningPart: isOpenAIReasoningPart,
+        hasOpenAIItemIdForPart,
+        getNextPart: (parts, index) => parts[index + 1],
+      });
 
-      for (let index = 0; index < content.length; index += 1) {
-        const part = content[index];
-        if (!isOpenAIReasoningPart(part)) {
-          parts.push(part);
-          continue;
-        }
-
-        const next = content[index + 1];
-
-        if (!next) {
-          changed = true;
-          continue;
-        }
-        if (isOpenAIReasoningPart(next)) {
-          changed = true;
-          continue;
-        }
-        if (!hasOpenAIItemIdForPart(next)) {
-          changed = true;
-          continue;
-        }
-
-        parts.push(part);
-      }
-
-      if (parts.length === content.length) {
+      if (!partsChanged) {
         return message;
       }
 
@@ -101,9 +67,11 @@ export const stripDanglingOpenAIReasoningFromModelMessages = (
         return null;
       }
 
+      const assistantMessage = message as Extract<ModelMessage, { role: "assistant" }>;
+
       return {
-        ...message,
-        content: parts as ModelMessage["content"],
+        ...assistantMessage,
+        content: parts as typeof assistantMessage.content,
       } satisfies ModelMessage;
     })
     .filter((message): message is ModelMessage => Boolean(message));
diff --git a/packages/core/src/agent/openai-reasoning-utils.ts b/packages/core/src/agent/openai-reasoning-utils.ts
new file mode 100644
index 000000000..329688d31
--- /dev/null
+++ b/packages/core/src/agent/openai-reasoning-utils.ts
@@ -0,0 +1,78 @@
+export const OPENAI_REASONING_ID_PREFIX = "rs_";
+
+export const isObject = (value: unknown): value is Record<string, unknown> =>
+  typeof value === "object" && value !== null;
+
+export const isOpenAIReasoningId = (value: string): boolean =>
+  value.trim().startsWith(OPENAI_REASONING_ID_PREFIX);
+
+export const hasOpenAIItemId = (metadata: unknown): boolean => {
+  if (!isObject(metadata)) {
+    return false;
+  }
+  const openai = (metadata as { openai?: unknown }).openai;
+  if (!isObject(openai)) {
+    return false;
+  }
+  const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : "";
+  return Boolean(itemId);
+};
+
+type OpenAIItemIdAccessors<TPart> = {
+  getProviderMetadata: (part: TPart) => unknown;
+  isToolPart?: (part: TPart) => boolean;
+  getCallProviderMetadata?: (part: TPart) => unknown;
+};
+
+export const hasOpenAIItemIdForPart = <TPart>(
+  part: TPart,
+  accessors: OpenAIItemIdAccessors<TPart>,
+): boolean => {
+  if (accessors.isToolPart?.(part)) {
+    const callProviderMetadata = accessors.getCallProviderMetadata?.(part);
+    if (callProviderMetadata !== undefined && hasOpenAIItemId(callProviderMetadata)) {
+      return true;
+    }
+  }
+  return hasOpenAIItemId(accessors.getProviderMetadata(part));
+};
+
+type StripDanglingOpenAIReasoningOptions<TPart> = {
+  isReasoningPart: (part: TPart) => boolean;
+  hasOpenAIItemIdForPart: (part: TPart) => boolean;
+  getNextPart: (parts: TPart[], index: number) => TPart | undefined;
+};
+
+export const stripDanglingOpenAIReasoningFromParts = <TPart>(
+  parts: TPart[],
+  options: StripDanglingOpenAIReasoningOptions<TPart>,
+): { parts: TPart[]; changed: boolean } => {
+  const sanitized: TPart[] = [];
+  let changed = false;
+
+  for (let index = 0; index < parts.length; index += 1) {
+    const part = parts[index];
+    if (!options.isReasoningPart(part)) {
+      sanitized.push(part);
+      continue;
+    }
+
+    const next = options.getNextPart(parts, index);
+    if (!next) {
+      changed = true;
+      continue;
+    }
+    if (options.isReasoningPart(next)) {
+      changed = true;
+      continue;
+    }
+    if (!options.hasOpenAIItemIdForPart(next)) {
+      changed = true;
+      continue;
+    }
+
+    sanitized.push(part);
+  }
+
+  return { parts: sanitized, changed };
+};
