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:
@@ -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);
|
||||
|
||||
+24
@@ -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;
|
||||
|
||||
+137
@@ -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');
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+19
-21
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user