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:
+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