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:
+60
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
+36
-1
@@ -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');
|
||||
|
||||
|
||||
+17
@@ -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();
|
||||
|
||||
|
||||
+27
-9
@@ -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;
|
||||
});
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user