## Issue Some AI chat threads become permanently broken. Every turn fails with an Anthropic 400: messages.5.content.1: tool_use ids must be unique The error is on the message *history*, so once a thread is in this state every subsequent turn fails too, not just the one that triggered it. It surfaces most visibly when aborting a thread and continuing it, but the abort is incidental: it just replays the already-corrupted history. ## Root cause A single tool call gets persisted as **two message parts sharing one `toolCallId`**. Confirmed in the DB for the affected thread, one assistant message held: - `tool-extract_json_paths` and `dynamic-tool`, both `toolu_01FXxxfBYP27NZEvA6AZ3AJc` - `tool-search_output` and `dynamic-tool`, both `toolu_01VqnFrYdGCERAc2dbG3zAyx` On the next turn `convertToModelMessages` turns each pair into two `tool_use` blocks with the same id, which Anthropic rejects. ## Why it happens It is not two concurrent calls. It is one call the AI SDK classifies inconsistently across its own stream chunks. The chat only exposes a small set of directly-callable tools (`execute_tool`, `learn_tools`, `load_skills`, `ask_questions`, plus native/preloaded ones). Registry tools like `extract_json_paths` and `search_output` are reachable only through `execute_tool`. When the model shortcuts that and calls one directly, the name is not in the active `ToolSet`, and the SDK does this: 1. On `tool-input-start`, `dynamic` is derived from the tool set: `tools[name]?.type === "dynamic"`. The tool is absent, so `dynamic: false`, and a **static** `tool-<name>` part is created. 2. On finalization the unknown tool throws `NoSuchToolError`. `repairToolCall` intentionally skips name errors (`return null`), so the SDK re-emits the call with a hardcoded `dynamic: true`. That error routes to the **dynamic** path and creates a second `dynamic-tool` part with the same id. The UI-message builder keeps static and dynamic tool parts in separate buckets, each searched by `toolCallId` independently, so the mid-call static-to-dynamic flip produces two parts for one call. Both persist and break the next turn. ## Fix Two independent read/convert-path passes, both in `sanitizeMessagePartsForModel` in `chat-execution.service.ts`, running before `convertToModelMessages`: 1. **`finalizeDanglingToolParts`** now dedupes tool parts by `toolCallId` (first-wins), keeping the `input-streaming` filter ahead of the dedup so a leading streaming duplicate can't strand the call. Because it runs before conversion and on the write paths too, already-corrupted threads are un-bricked on their next turn with no migration. 2. **`guideUncallableToolCallsToMetaTool`** addresses the behavior that caused it: when the model calls a tool that is not directly callable, it appends the `learn_tools` -> `execute_tool` flow to that failed tool result, so the model reads how to reach the tool. Detection is structural (a failed tool part whose name is not in the active tool set), not string-matched against the SDK's error wording. Unit tests added for both. Typecheck, lint, and the suite pass. Note: the stale duplicate rows already in the DB are harmless (deduped on every read); no migration is required. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23277?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. -->
The #1 Open-Source CRM
Website ·
Documentation ·
Roadmap ·
Discord ·
Figma
Why Twenty
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
Learn more about why we built Twenty
Installation
Cloud
The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
Build an app
Scaffold a new app with the Twenty CLI:
npx create-twenty-app my-app
Define objects, fields, and views as code:
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
Then ship it to your workspace:
npx twenty app:publish --private
See the app development guide for objects, views, agents, and logic functions.
Self-hosting
Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.
Everything you need
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the User Guide for product walkthroughs, or the
Documentation for developer reference.
|
|
|
|
|
|
Stack
TypeScript
Nx
NestJS, with BullMQ,
PostgreSQL,
Redis
React, with Jotai, Linaria and Lingui
Thanks
Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
Star the repo ·
Discord ·
Feature requests ·
Releases ·
X ·
LinkedIn ·
Crowdin ·
Contribute





