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>
This commit is contained in:
Félix Malfait
2026-06-17 18:12:21 +02:00
committed by GitHub
parent 105f9565a5
commit 02a3a3c47c
9 changed files with 217 additions and 43 deletions
@@ -9,7 +9,7 @@ import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
import { groupContiguousThinkingStepParts } from '@/ai/utils/groupContiguousThinkingStepParts';
import { isCodeInterpreterToolPart } from '@/ai/utils/isCodeInterpreterToolPart';
import { styled } from '@linaria/react';
import { isToolUIPart, type ToolUIPart } from 'ai';
import { isToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -75,13 +75,8 @@ const MessagePartRenderer = ({
/>
);
default:
if (isToolUIPart(part) === true && part.type !== 'dynamic-tool') {
return (
<ToolStepRenderer
toolPart={part as ToolUIPart}
isStreaming={isStreaming}
/>
);
if (isToolUIPart(part)) {
return <ToolStepRenderer toolPart={part} isStreaming={isStreaming} />;
}
return null;
}
@@ -1,7 +1,7 @@
import { styled } from '@linaria/react';
import { plural, t } from '@lingui/core/macro';
import { useState } from 'react';
import { type ToolUIPart } from 'ai';
import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronRight,
@@ -258,12 +258,12 @@ const ThinkingToolStepRow = ({
rowIndex,
}: {
isActive: boolean;
part: ToolUIPart;
part: ToolUIPart | DynamicToolUIPart;
rowIndex: number;
}) => {
const { copyToClipboard } = useCopyToClipboard();
const [isExpanded, setIsExpanded] = useState(false);
const rawToolName = part.type.split('-')[1];
const rawToolName = getToolName(part);
const { resolvedInput: toolInput, resolvedToolName } = resolveToolInput(
part.input,
rawToolName,
@@ -14,7 +14,7 @@ import {
} from '@/ai/utils/getToolDisplayMessage';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { useLingui } from '@lingui/react/macro';
import { type ToolUIPart } from 'ai';
import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
@@ -131,7 +131,7 @@ export const ToolStepRenderer = ({
toolPart,
isStreaming,
}: {
toolPart: ToolUIPart;
toolPart: ToolUIPart | DynamicToolUIPart;
isStreaming: boolean;
}) => {
const { theme } = useContext(ThemeContext);
@@ -140,8 +140,8 @@ export const ToolStepRenderer = ({
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, type, errorText } = toolPart;
const rawToolName = type.split('-')[1];
const { input, output, errorText } = toolPart;
const rawToolName = getToolName(toolPart);
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
@@ -233,4 +233,28 @@ describe('AiChatAssistantMessageRenderer', () => {
);
expect(screen.getByTestId('code-execution-display')).toBeInTheDocument();
});
it('should group a dynamic-tool part (native web search) into ThinkingStepsDisplay', () => {
const messageParts = [
{
type: 'dynamic-tool',
toolName: 'web_search',
toolCallId: 'dyn-1',
input: { query: 'crm software' },
output: { result: { ok: true } },
state: 'output-available',
providerExecuted: true,
},
{
type: 'text',
text: 'Final answer',
},
] as ExtendedUIMessagePart[];
renderAssistantRenderer(messageParts);
expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent(
'thinking-1-answer-started',
);
});
});
@@ -1,4 +1,4 @@
import { type ReasoningUIPart, type ToolUIPart } from 'ai';
import { type ReasoningUIPart } from 'ai';
import {
type ExtendedFileUIPart,
type ExtendedUIMessagePart,
@@ -63,9 +63,13 @@ export const mapDBPartToUIMessagePart = (
};
default:
{
if (part.type.includes('tool-') === true) {
const isStaticToolPart = part.type.startsWith('tool-');
const isDynamicToolPart = part.type === 'dynamic-tool';
if (isStaticToolPart || isDynamicToolPart) {
return {
type: part.type as `tool-${string}`,
type: part.type as `tool-${string}` | 'dynamic-tool',
...(isDynamicToolPart && { toolName: part.toolName ?? '' }),
toolCallId: part.toolCallId!,
input: part.toolInput ?? {},
output: part.toolOutput,
@@ -74,7 +78,10 @@ export const mapDBPartToUIMessagePart = (
...(part.providerExecuted != null && {
providerExecuted: part.providerExecuted,
}),
} as ToolUIPart;
...(part.providerMetadata != null && {
callProviderMetadata: part.providerMetadata,
}),
} as ExtendedUIMessagePart;
}
}
throw new Error(`Unsupported part type: ${part.type}`);
@@ -1,3 +1,7 @@
import { type ReasoningUIPart, type ToolUIPart } from 'ai';
import {
type DynamicToolUIPart,
type ReasoningUIPart,
type ToolUIPart,
} from 'ai';
export type ThinkingStepPart = ReasoningUIPart | ToolUIPart;
export type ThinkingStepPart = ReasoningUIPart | ToolUIPart | DynamicToolUIPart;
@@ -0,0 +1,137 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
import { mapDBPartToUIMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart';
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts';
const dynamicToolPart = (
overrides: Record<string, unknown> = {},
): ExtendedUIMessagePart =>
({
type: 'dynamic-tool',
toolName: 'unknown_remote_tool',
toolCallId: 'call_dyn_1',
state: 'output-available',
input: { query: 'hello' },
output: { ok: true },
...overrides,
}) as unknown as ExtendedUIMessagePart;
const staticToolPart = (
overrides: Record<string, unknown> = {},
): ExtendedUIMessagePart =>
({
type: 'tool-execute_tool',
toolCallId: 'call_static_1',
state: 'output-available',
input: { name: 'foo' },
output: { ok: true },
...overrides,
}) as unknown as ExtendedUIMessagePart;
describe('AgentMessagePart mappers — dynamic-tool support', () => {
it('persists a dynamic-tool part without throwing', () => {
expect(() =>
mapUIMessagePartsToDBParts(
[dynamicToolPart()],
'message-1',
'workspace-1',
),
).not.toThrow();
});
it('stores the tool name on the row for dynamic-tool parts', () => {
const [row] = mapUIMessagePartsToDBParts(
[dynamicToolPart()],
'message-1',
'workspace-1',
);
expect(row).toMatchObject({
type: 'dynamic-tool',
toolName: 'unknown_remote_tool',
toolCallId: 'call_dyn_1',
toolInput: { query: 'hello' },
toolOutput: { ok: true },
});
});
it('stores the tool name on the row for static tool parts', () => {
const [row] = mapUIMessagePartsToDBParts(
[staticToolPart()],
'message-1',
'workspace-1',
);
expect(row).toMatchObject({
type: 'tool-execute_tool',
toolName: 'execute_tool',
toolCallId: 'call_static_1',
});
});
it('round-trips a dynamic-tool part through DB and back', () => {
const original = dynamicToolPart();
const [row] = mapUIMessagePartsToDBParts(
[original],
'message-1',
'workspace-1',
);
const reloaded = mapDBPartToUIMessagePart(row as AgentMessagePartEntity);
expect(reloaded).toEqual({
type: 'dynamic-tool',
toolName: 'unknown_remote_tool',
toolCallId: 'call_dyn_1',
input: { query: 'hello' },
output: { ok: true },
errorText: '',
state: 'output-available',
});
});
it('round-trips callProviderMetadata for provider-executed tools', () => {
const original = dynamicToolPart({
providerExecuted: true,
callProviderMetadata: { anthropic: { encryptedContent: 'abc123' } },
});
const [row] = mapUIMessagePartsToDBParts(
[original],
'message-1',
'workspace-1',
);
expect(row).toMatchObject({
providerExecuted: true,
providerMetadata: { anthropic: { encryptedContent: 'abc123' } },
});
const reloaded = mapDBPartToUIMessagePart(row as AgentMessagePartEntity);
expect(reloaded).toMatchObject({
providerExecuted: true,
callProviderMetadata: { anthropic: { encryptedContent: 'abc123' } },
});
});
it('round-trips a static tool part through DB and back', () => {
const original = staticToolPart();
const [row] = mapUIMessagePartsToDBParts(
[original],
'message-1',
'workspace-1',
);
const reloaded = mapDBPartToUIMessagePart(row as AgentMessagePartEntity);
expect(reloaded).toMatchObject({
type: 'tool-execute_tool',
toolCallId: 'call_static_1',
input: { name: 'foo' },
output: { ok: true },
});
expect(reloaded).not.toHaveProperty('toolName');
});
});
@@ -56,9 +56,15 @@ export const mapDBPartToUIMessagePart = (
case 'data-routing-status':
return null;
default: {
if (part.type.includes('tool-') && part.toolCallId) {
const isStaticToolPart =
part.type.startsWith('tool-') && part.toolCallId !== null;
const isDynamicToolPart =
part.type === 'dynamic-tool' && part.toolCallId !== null;
if (isStaticToolPart || isDynamicToolPart) {
return {
type: part.type,
...(isDynamicToolPart && { toolName: part.toolName ?? '' }),
toolCallId: part.toolCallId,
input: part.toolInput ?? {},
output: part.toolOutput,
@@ -67,6 +73,9 @@ export const mapDBPartToUIMessagePart = (
...(part.providerExecuted != null && {
providerExecuted: part.providerExecuted,
}),
...(part.providerMetadata != null && {
callProviderMetadata: part.providerMetadata,
}),
} as ExtendedUIMessagePart;
}
@@ -1,4 +1,4 @@
import { type ToolUIPart } from 'ai';
import { getToolName, isToolUIPart } from 'ai';
import {
isExtendedFileUIPart,
type ExtendedUIMessagePart,
@@ -6,10 +6,6 @@ import {
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
const isToolPart = (part: ExtendedUIMessagePart): part is ToolUIPart => {
return part.type.includes('tool-') && 'toolCallId' in part;
};
export const mapUIMessagePartsToDBParts = (
uiMessageParts: ExtendedUIMessagePart[],
messageId: string,
@@ -80,23 +76,25 @@ export const mapUIMessagePartsToDBParts = (
case 'data-thread-title':
// Thread title is a transient notification for the client
return null;
default:
{
if (isToolPart(part)) {
const { toolCallId, input, output, errorText, state } = part;
return {
...basePart,
toolCallId: toolCallId,
toolInput: input,
toolOutput: output,
errorMessage: errorText,
state,
providerExecuted: part.providerExecuted ?? null,
};
}
default: {
if (isToolUIPart(part)) {
return {
...basePart,
toolName: getToolName(part),
toolCallId: part.toolCallId,
toolInput: part.input,
toolOutput: part.output,
errorMessage: part.errorText,
state: part.state,
providerExecuted: part.providerExecuted ?? null,
providerMetadata: part.callProviderMetadata ?? null,
};
}
throw new Error(`Unsupported part type: ${part.type}`);
throw new Error(
`Unsupported part type: ${(part as { type: string }).type}`,
);
}
}
})
.filter((part): part is Partial<AgentMessagePartEntity> => part !== null);