fix(ai): prevent chat thread bricking from tool parts with null input (#21752)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21752?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-17 21:13:51 +02:00
committed by GitHub
parent a1f79c4f40
commit f96e36d3e6
5 changed files with 142 additions and 11 deletions
@@ -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<unknown[]> => {
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({});
});
});
@@ -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');
@@ -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();
@@ -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;
});
@@ -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,