From f96e36d3e67510eaf81ee130d8d56c3db563ec3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 17 Jun 2026 21:13:51 +0200 Subject: [PATCH] fix(ai): prevent chat thread bricking from tool parts with null input (#21752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Fixes #21695. An AI chat thread became **permanently unusable** — every subsequent message failed with `AI_APICallError: Internal server error` from Anthropic — when the thread history contained a tool part in `output-error` state with a **null input** (e.g. a tool call that failed input validation before execution, so neither `toolInput` nor `toolOutput` was ever captured). ## Validation of the reported findings I reproduced and confirmed the root cause empirically against the pinned `ai@6.0.97` SDK before writing the fix. **Root cause (confirmed from SDK source).** `convertToModelMessages` serializes every non-`input-streaming` tool part into a provider `tool_use` block, and for errored parts it uses: ```ts input: part.state === 'output-error' ? (part.input ?? ('rawInput' in part ? part.rawInput : undefined)) : part.input, ``` When both `input` and `rawInput` are nullish, the block is built with `input: undefined`, which `JSON.stringify` drops — so the HTTP payload carries a `tool_use` with **no `input` field**. This matches the reporter's minimal repro exactly (no `input` → `400 Field required`; `input: {}` → `200`). Inside a large streamed conversation the same malformed block surfaces as the generic `500`, and because the bad part is replayed on every turn the thread stays bricked. **Why #21276 didn't catch it.** `finalizeDanglingToolParts` only rewrote `input-available` parts; a part that arrives already in `output-error` with a null input was passed through untouched. **Note on current `main`.** A read-path default added recently (`mapDBPartToUIMessagePart`: `input: part.toolInput ?? {}`) already masks the live 500 on the standard reload path. However the gap is real and worth closing: the persist path still writes `toolInput = NULL` (the exact malformed rows the reporter found in `core."agentMessagePart"`), `finalizeDanglingToolParts` still doesn't normalize this case, and the protection rested on a single implicit default with no regression coverage. A small repro harness confirmed all of this: persisted `toolInput` was `undefined`, and a raw (non-defaulted) `output-error` part produced a `tool-call` whose `input` value was `undefined`. ## Fix Defense-in-depth so the invariant *"a tool part always carries a defined input"* holds at both the finalize and storage boundaries: - **`finalizeDanglingToolParts`** now backfills `input: {}` for `output-error` parts whose input is null, while preserving the original error message. This is the natural chokepoint (it already runs immediately before every persist). - **`mapUIMessagePartsToDBParts`** defaults a nullish tool input to `{}` so malformed rows are never persisted, independent of the caller. The existing read-path `?? {}` default is kept as a third safety net. ## Tests - Unit tests for `finalizeDanglingToolParts`: backfills `{}` for an `output-error` part missing its input, and preserves the existing validation error message. - Persistence test: `mapUIMessagePartsToDBParts` stores `{}` (never `null`) for a missing input. - End-to-end round-trip test: after finalize → persist → reload, `convertToModelMessages` produces a `tool-call` with a defined input and the errored call stays resolved. All three new core assertions were verified to **fail without the fix** and pass with it. Full AI module suite (97 tests) passes; `oxlint --type-aware`, `oxfmt`, and `tsgo` typecheck are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SpuX6Pp2yTevk1zKTRiB9G --- _Generated by [Claude Code](https://claude.ai/code/session_01SpuX6Pp2yTevk1zKTRiB9G)_ Review in cubic --------- Co-authored-by: Claude --- ...lize-dangling-tool-parts.roundtrip.spec.ts | 60 +++++++++++++++++++ .../finalize-dangling-tool-parts.util.spec.ts | 37 +++++++++++- .../map-message-parts.dynamic-tool.spec.ts | 17 ++++++ .../finalize-dangling-tool-parts.util.ts | 36 ++++++++--- .../utils/mapUIMessagePartsToDBParts.ts | 3 +- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.roundtrip.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.roundtrip.spec.ts index 9837fd2b2e..4946d30141 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.roundtrip.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.roundtrip.spec.ts @@ -68,6 +68,27 @@ const unresolvedToolCallIds = async ( return [...pending]; }; +// convertToModelMessages drops the `input` field when a tool part's input is +// nullish, so every reconstructed tool-call must carry a defined input. +const toolCallInputs = async (messages: UIMessage[]): Promise => { + const modelMessages = await convertToModelMessages(messages); + const inputs: unknown[] = []; + + for (const message of modelMessages) { + if (!Array.isArray(message.content)) { + continue; + } + + for (const content of message.content) { + if (typeof content === 'object' && content.type === 'tool-call') { + inputs.push(content.input); + } + } + } + + return inputs; +}; + describe('finalizeDanglingToolParts round-trip', () => { const interruptedBatch: ExtendedUIMessagePart[] = [ { type: 'text', text: 'Creating items…' } as ExtendedUIMessagePart, @@ -127,4 +148,43 @@ describe('finalizeDanglingToolParts round-trip', () => { ]), ); }); + + // A tool call that failed input validation: persisted as output-error with + // a null input (issue #21695). + const validationErroredPart: ExtendedUIMessagePart = { + type: 'tool-execute_tool', + toolCallId: 'validation_failed_1', + state: 'output-error', + errorText: 'Invalid input for tool execute_tool: Type validation failed', + } as unknown as ExtendedUIMessagePart; + + it('replays a validation-errored tool part with a defined input', async () => { + const reloaded = persistAndReload( + finalizeDanglingToolParts([validationErroredPart]), + ); + + const inputs = await toolCallInputs(buildThread(reloaded)); + + expect(inputs).toHaveLength(1); + expect(inputs[0]).toBeDefined(); + expect(inputs[0]).toEqual({}); + }); + + it('keeps a validation-errored tool call resolved after the round-trip', async () => { + const reloaded = persistAndReload( + finalizeDanglingToolParts([validationErroredPart]), + ); + + expect(await unresolvedToolCallIds(buildThread(reloaded))).toEqual([]); + }); + + it('persists an empty object rather than null for a missing tool input', () => { + const [dbPart] = mapUIMessagePartsToDBParts( + finalizeDanglingToolParts([validationErroredPart]), + 'message-1', + 'workspace-1', + ); + + expect(dbPart.toolInput).toEqual({}); + }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts index beb9ad1f9f..8d3b15a030 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/finalize-dangling-tool-parts.util.spec.ts @@ -39,12 +39,47 @@ describe('finalizeDanglingToolParts', () => { expect(finalizeDanglingToolParts([part])).toEqual([part]); }); - it('leaves an errored tool part untouched', () => { + it('leaves an errored tool part with an input untouched', () => { const part = buildToolPart('output-error', { errorText: 'boom' }); expect(finalizeDanglingToolParts([part])).toEqual([part]); }); + it('backfills an empty input for an output-error part missing its input', () => { + const part = buildToolPart('output-error', { + input: undefined, + errorText: 'Invalid input for tool execute_tool: Type validation failed', + }); + + expect(finalizeDanglingToolParts([part])).toEqual([ + { + type: 'tool-execute_tool', + toolCallId: 'call_1', + input: {}, + state: 'output-error', + errorText: + 'Invalid input for tool execute_tool: Type validation failed', + }, + ]); + }); + + it('preserves the existing error message when backfilling input', () => { + const part = buildToolPart('output-error', { + input: null, + errorText: 'original validation error', + }); + + expect(finalizeDanglingToolParts([part])).toEqual([ + { + type: 'tool-execute_tool', + toolCallId: 'call_1', + input: {}, + state: 'output-error', + errorText: 'original validation error', + }, + ]); + }); + it('drops an input-streaming tool part with incomplete arguments', () => { const part = buildToolPart('input-streaming'); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-message-parts.dynamic-tool.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-message-parts.dynamic-tool.spec.ts index e795e7abd3..8e1541b1ef 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-message-parts.dynamic-tool.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/map-message-parts.dynamic-tool.spec.ts @@ -70,6 +70,23 @@ describe('AgentMessagePart mappers — dynamic-tool support', () => { }); }); + it('defaults a missing tool input to an empty object on persist (issue #21695)', () => { + const [row] = mapUIMessagePartsToDBParts( + [ + staticToolPart({ + state: 'output-error', + input: undefined, + output: undefined, + errorText: 'Invalid input for tool execute_tool', + }), + ], + 'message-1', + 'workspace-1', + ); + + expect(row.toolInput).toEqual({}); + }); + it('round-trips a dynamic-tool part through DB and back', () => { const original = dynamicToolPart(); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts index 2bbe17f007..6b6c1b677e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts @@ -3,17 +3,35 @@ import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; const INTERRUPTED_TOOL_ERROR_TEXT = 'Tool execution was interrupted.'; +// A tool part with a nullish input serializes to a `tool_use` block with no +// `input` field, which Anthropic rejects — bricking every later turn (#21695). export const finalizeDanglingToolParts = ( parts: ExtendedUIMessagePart[], ): ExtendedUIMessagePart[] => parts .filter((part) => !(isToolUIPart(part) && part.state === 'input-streaming')) - .map((part) => - isToolUIPart(part) && part.state === 'input-available' - ? ({ - ...part, - state: 'output-error', - errorText: INTERRUPTED_TOOL_ERROR_TEXT, - } as ExtendedUIMessagePart) - : part, - ); + .map((part) => { + if (!isToolUIPart(part)) { + return part; + } + + // Dangling call interrupted mid-flight: resolve it as an error. + if (part.state === 'input-available') { + return { + ...part, + state: 'output-error', + input: part.input ?? {}, + errorText: INTERRUPTED_TOOL_ERROR_TEXT, + } as ExtendedUIMessagePart; + } + + // Errored before its input was captured (e.g. failed input validation). + if (part.state === 'output-error' && part.input == null) { + return { + ...part, + input: {}, + } as ExtendedUIMessagePart; + } + + return part; + }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts index 3435d35fd7..9c7eed7ba8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts @@ -82,7 +82,8 @@ export const mapUIMessagePartsToDBParts = ( ...basePart, toolName: getToolName(part), toolCallId: part.toolCallId, - toolInput: part.input, + // A nullish input yields an invalid tool_use block (#21695). + toolInput: part.input ?? {}, toolOutput: part.output, errorMessage: part.errorText, state: part.state,