Files
twenty/packages
Félix Malfait 02a3a3c47c fix(ai): handle dynamic-tool message parts in chat persistence (#21740)
## Summary

Fixes #20558. AI chat streams crashed with `Unsupported part type:
dynamic-tool` whenever the model emitted a *dynamic* tool call (a tool
that isn't part of the bound schema). The assistant message never
persisted, so the user saw a hard failure mid-stream.

## Root cause

The AI SDK v6 emits two flavors of tool parts:
- **Static** — `type: "tool-<toolName>"` (e.g. `tool-execute_tool`)
- **Dynamic** — `type: "dynamic-tool"`, with the name on `part.toolName`

`mapUIMessagePartsToDBParts` recognised tool parts with a homegrown
check:

```ts
part.type.includes('tool-') && 'toolCallId' in part
```

That returns `false` for `'dynamic-tool'` (it contains `-tool`, not
`tool-`), so dynamic parts fell through to `throw new
Error(\`Unsupported part type: ${part.type}\`)` during the
`handleStreamFinish` persistence step. Stack trace from the issue
matches exactly.

The same broken heuristic was duplicated in:
- `packages/twenty-server/.../mapDBPartToUIMessagePart.ts` (reverse
mapper)
- `packages/twenty-front/.../utils/mapDBPartToUIMessagePart.ts`
(frontend mirror — would also throw on a `dynamic-tool` row reloaded
from history)

Meanwhile, two other call sites in the codebase
(`finalize-dangling-tool-parts.util.ts`, `isThinkingStepPart.ts`)
already correctly use the SDK's `isToolUIPart`, which natively
recognises both flavors.

## What this PR does

1. **Switches all three mappers to the SDK's canonical check**
(`isToolUIPart` on the forward path; explicit `dynamic-tool` + `tool-`
startsWith on the reverse paths, where the input is an entity/DTO, not a
UI part).
2. **Persists `toolName`** — the column already existed on the entity,
DTO and GraphQL fragment but nothing wrote it. For static parts the name
is recoverable from `type`; for dynamic parts it's the only place the
name lives, so without it the round-trip is impossible. The shared
denormalisation also helps existing per-tool analytics
(`count-native-web-search-calls-from-steps.util.ts`).
3. **Reconstructs `dynamic-tool` parts on read** (with `toolName`) so
they survive a DB round-trip both on the server and on the frontend
history view.
4. **Adds a round-trip unit test** covering both `dynamic-tool` and a
static tool part to lock the behavior in.

## Architecture notes (called out for review)

- `mapDBPartToUIMessagePart` is duplicated frontend + backend because
the input shape differs (TypeORM entity vs. GraphQL DTO). Out of scope
to consolidate here, but they're drifting — this PR is what that drift
looked like in production. Worth a follow-up to express the shared logic
once over a unified row type.
- I left the existing renderer guard `part.type !== 'dynamic-tool'` in
`AiChatAssistantMessageRenderer.tsx` alone — it's a reasonable UI-side
decision to not attempt to render an unknown dynamic tool generically.
Persistence and history reload now work; rendering of dynamic tool calls
is a separate UX decision.
- No DB migration needed — the `toolName` column already exists. Old
static rows have `toolName: null`; the reverse mapper recovers their
name from the `type` column as before. Old dynamic-tool rows don't exist
(they all threw on write).

## Test plan
- [x] `yarn workspace twenty-server jest map-message-parts.dynamic-tool`
— 5 passed
- [x] `yarn workspace twenty-server jest
finalize-dangling-tool-parts.roundtrip` — still 4 passed (no regression)
- [x] `yarn nx typecheck twenty-server` — clean
- [x] `yarn nx typecheck twenty-front` — clean
- [x] `yarn nx lint:diff-with-main twenty-server` — clean
- [x] `yarn nx lint:diff-with-main twenty-front` — clean
- [ ] Manual: trigger an AI chat that exercises a dynamic tool (e.g. via
an MCP server returning a tool not in the bound schema) and confirm the
stream finishes and the message persists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013EE11eVWtyxmdcbEHVJKoc

---
_Generated by [Claude
Code](https://claude.ai/code/session_013EE11eVWtyxmdcbEHVJKoc)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21740?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>
2026-06-17 18:12:21 +02:00
..