Skip to content

Commit 6df22a5

Browse files
authored
Merge pull request #1082 from objectstack-ai/copilot/fix-agent-chat-message-format
2 parents 7d690ae + 41bf674 commit 6df22a5

File tree

6 files changed

+179
-72
lines changed

6 files changed

+179
-72
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- **Agent Chat: Vercel AI SDK v6 `parts` format support** — The agent chat endpoint
12+
(`/api/v1/ai/agents/:agentName/chat`) now accepts Vercel AI SDK v6 `parts`-based message
13+
format in addition to the legacy `content` string format. Previously, sending messages
14+
with `parts` (as `useChat` v6 does by default) resulted in a 400 error:
15+
`"message.content must be a string"`. Shared validation and normalization utilities
16+
(`validateMessageContent`, `normalizeMessage`) are extracted into `message-utils.ts`
17+
for reuse across both the general chat and agent chat routes.
1118
- **Studio: Code tab now shows CodeExporter** — The Code tab in Studio metadata detail pages
1219
now correctly renders the `CodeExporter` component (TypeScript/JSON export with copy-to-clipboard)
1320
instead of always showing the JSON Inspector preview. The default plugin now registers two separate

packages/services/service-ai/src/__tests__/chatbot-features.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,78 @@ describe('Agent Routes', () => {
874874
// temperature is a safe key, should be passed through
875875
// tools/toolChoice/model should NOT be passed through
876876
});
877+
878+
// ── Vercel AI SDK v6 `parts` format support ──
879+
880+
it('should accept Vercel AI SDK v6 parts format messages', async () => {
881+
const chatRoute = routes.find(r => r.method === 'POST')!;
882+
const resp = await chatRoute.handler({
883+
params: { agentName: 'data_chat' },
884+
body: {
885+
messages: [
886+
{
887+
role: 'user',
888+
parts: [{ type: 'text', text: 'List all tables' }],
889+
},
890+
],
891+
},
892+
});
893+
expect(resp.status).toBe(200);
894+
expect((resp.body as any).content).toBe('Agent response');
895+
});
896+
897+
it('should accept mixed parts and content messages', async () => {
898+
const chatRoute = routes.find(r => r.method === 'POST')!;
899+
const resp = await chatRoute.handler({
900+
params: { agentName: 'data_chat' },
901+
body: {
902+
messages: [
903+
{ role: 'user', content: 'Hello' },
904+
{
905+
role: 'assistant',
906+
parts: [{ type: 'text', text: 'Hi there' }],
907+
},
908+
{
909+
role: 'user',
910+
parts: [{ type: 'text', text: 'List objects' }],
911+
},
912+
],
913+
},
914+
});
915+
expect(resp.status).toBe(200);
916+
});
917+
918+
it('should accept assistant message with parts and no content', async () => {
919+
const chatRoute = routes.find(r => r.method === 'POST')!;
920+
const resp = await chatRoute.handler({
921+
params: { agentName: 'data_chat' },
922+
body: {
923+
messages: [
924+
{
925+
role: 'assistant',
926+
parts: [{ type: 'text', text: 'previous response' }],
927+
},
928+
{
929+
role: 'user',
930+
parts: [{ type: 'text', text: 'follow up' }],
931+
},
932+
],
933+
},
934+
});
935+
expect(resp.status).toBe(200);
936+
});
937+
938+
it('should reject user message with neither content nor parts', async () => {
939+
const chatRoute = routes.find(r => r.method === 'POST')!;
940+
const resp = await chatRoute.handler({
941+
params: { agentName: 'data_chat' },
942+
body: {
943+
messages: [{ role: 'user' }],
944+
},
945+
});
946+
expect(resp.status).toBe(400);
947+
expect((resp.body as any).error).toContain('content');
948+
});
877949
});
878950

879951
// ═══════════════════════════════════════════════════════════════════

packages/services/service-ai/src/routes/agent-routes.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Logger } from '@objectstack/spec/contracts';
55
import type { AIService } from '../ai-service.js';
66
import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
77
import type { RouteDefinition } from './ai-routes.js';
8+
import { normalizeMessage, validateMessageContent } from './message-utils.js';
89

910
/**
1011
* Allowed message roles for the agent chat endpoint.
@@ -25,10 +26,10 @@ function validateAgentMessage(raw: unknown): string | null {
2526
if (typeof msg.role !== 'string' || !ALLOWED_AGENT_ROLES.has(msg.role)) {
2627
return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map(r => `"${r}"`).join(', ')} for agent chat`;
2728
}
28-
if (typeof msg.content !== 'string') {
29-
return 'message.content must be a string';
30-
}
31-
return null;
29+
30+
// Assistant messages may legitimately have empty content (e.g. tool-call-only)
31+
const allowEmpty = msg.role === 'assistant';
32+
return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
3233
}
3334

3435
/**
@@ -134,7 +135,7 @@ export function buildAgentRoutes(
134135
// Prepend system messages then user conversation
135136
const fullMessages: ModelMessage[] = [
136137
...systemMessages,
137-
...(rawMessages as ModelMessage[]),
138+
...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
138139
];
139140

140141
// Use chatWithTools for automatic tool resolution

packages/services/service-ai/src/routes/ai-routes.ts

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
44
import type { Logger } from '@objectstack/spec/contracts';
55
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6+
import { normalizeMessage, validateMessageContent } from './message-utils.js';
67

78
/**
89
* Minimal HTTP handler abstraction so routes stay framework-agnostic.
@@ -77,37 +78,6 @@ export interface RouteResponse {
7778
/** Valid message roles accepted by the AI routes. */
7879
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
7980

80-
/**
81-
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
82-
* `content`) into a plain `{ role, content }` ModelMessage.
83-
*/
84-
function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
85-
const role = raw.role as string;
86-
87-
// If content is already a string, use it directly
88-
if (typeof raw.content === 'string') {
89-
return { role, content: raw.content } as unknown as ModelMessage;
90-
}
91-
92-
// If content is an array (multi-part), pass through
93-
if (Array.isArray(raw.content)) {
94-
return { role, content: raw.content } as unknown as ModelMessage;
95-
}
96-
97-
// Vercel AI SDK v6: extract text from `parts` array
98-
if (Array.isArray(raw.parts)) {
99-
const textParts = (raw.parts as Array<Record<string, unknown>>)
100-
.filter(p => p.type === 'text' && typeof p.text === 'string')
101-
.map(p => p.text as string);
102-
if (textParts.length > 0) {
103-
return { role, content: textParts.join('') } as unknown as ModelMessage;
104-
}
105-
}
106-
107-
// Fallback: empty content (e.g. tool-only assistant messages)
108-
return { role, content: '' } as unknown as ModelMessage;
109-
}
110-
11181
/**
11282
* Validate that `raw` is a well-formed message.
11383
* Returns null on success, or an error string on failure.
@@ -125,44 +95,10 @@ function validateMessage(raw: unknown): string | null {
12595
if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
12696
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
12797
}
128-
const content = msg.content;
129-
130-
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
131-
// Accept any message that carries a `parts` array, even when `content` is absent.
132-
if (Array.isArray(msg.parts)) {
133-
return null;
134-
}
135-
136-
// content is a plain string — OK
137-
if (typeof content === 'string') {
138-
return null;
139-
}
140-
141-
// content is an array of typed parts (legacy multi-part format)
142-
if (Array.isArray(content)) {
143-
for (const part of content as unknown[]) {
144-
if (typeof part !== 'object' || part === null) {
145-
return 'message.content array elements must be non-null objects';
146-
}
147-
const partObj = part as Record<string, unknown>;
148-
if (typeof partObj.type !== 'string') {
149-
return 'each message.content array element must have a string "type" property';
150-
}
151-
if (partObj.type === 'text' && typeof partObj.text !== 'string') {
152-
return 'message.content elements with type "text" must have a string "text" property';
153-
}
154-
}
155-
return null;
156-
}
15798

15899
// Assistant / tool messages may legitimately have null or missing content
159-
if (content === null || content === undefined) {
160-
if (msg.role === 'assistant' || msg.role === 'tool') {
161-
return null;
162-
}
163-
}
164-
165-
return 'message.content must be a string, an array, or include parts';
100+
const allowEmpty = msg.role === 'assistant' || msg.role === 'tool';
101+
return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
166102
}
167103

168104
/**

packages/services/service-ai/src/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
export { buildAIRoutes } from './ai-routes.js';
44
export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
5+
export { normalizeMessage, validateMessageContent } from './message-utils.js';
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { ModelMessage } from '@objectstack/spec/contracts';
4+
5+
/**
6+
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
7+
* `content`) into a plain `{ role, content }` ModelMessage.
8+
*
9+
* Shared between the general chat routes and agent chat routes.
10+
*/
11+
export function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
12+
const role = raw.role as string;
13+
14+
// If content is already a string, use it directly
15+
if (typeof raw.content === 'string') {
16+
return { role, content: raw.content } as unknown as ModelMessage;
17+
}
18+
19+
// If content is an array (multi-part), pass through
20+
if (Array.isArray(raw.content)) {
21+
return { role, content: raw.content } as unknown as ModelMessage;
22+
}
23+
24+
// Vercel AI SDK v6: extract text from `parts` array
25+
if (Array.isArray(raw.parts)) {
26+
const textParts = (raw.parts as Array<Record<string, unknown>>)
27+
.filter(p => p.type === 'text' && typeof p.text === 'string')
28+
.map(p => p.text as string);
29+
if (textParts.length > 0) {
30+
return { role, content: textParts.join('') } as unknown as ModelMessage;
31+
}
32+
}
33+
34+
// Fallback: empty content (e.g. tool-only assistant messages)
35+
return { role, content: '' } as unknown as ModelMessage;
36+
}
37+
38+
/**
39+
* Validate message content/parts format (role-agnostic).
40+
*
41+
* Returns `null` when the content shape is valid, or an error string
42+
* describing the first violation found.
43+
*
44+
* Accepts:
45+
* - Simple string `content` (legacy)
46+
* - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
47+
* - Vercel AI SDK v6 `parts` format (content may be absent/null)
48+
* - Null/undefined `content` for assistant messages (when `allowEmpty` is true)
49+
*/
50+
export function validateMessageContent(
51+
msg: Record<string, unknown>,
52+
opts?: { allowEmptyContent?: boolean },
53+
): string | null {
54+
const content = msg.content;
55+
56+
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
57+
// Accept any message that carries a `parts` array, even when `content` is absent.
58+
if (Array.isArray(msg.parts)) {
59+
return null;
60+
}
61+
62+
// content is a plain string — OK
63+
if (typeof content === 'string') {
64+
return null;
65+
}
66+
67+
// content is an array of typed parts (legacy multi-part format)
68+
if (Array.isArray(content)) {
69+
for (const part of content as unknown[]) {
70+
if (typeof part !== 'object' || part === null) {
71+
return 'message.content array elements must be non-null objects';
72+
}
73+
const partObj = part as Record<string, unknown>;
74+
if (typeof partObj.type !== 'string') {
75+
return 'each message.content array element must have a string "type" property';
76+
}
77+
if (partObj.type === 'text' && typeof partObj.text !== 'string') {
78+
return 'message.content elements with type "text" must have a string "text" property';
79+
}
80+
}
81+
return null;
82+
}
83+
84+
// Allow empty content for certain roles (e.g. assistant tool-call messages)
85+
if ((content === null || content === undefined) && opts?.allowEmptyContent) {
86+
return null;
87+
}
88+
89+
return 'message.content must be a string, an array, or include parts';
90+
}

0 commit comments

Comments
 (0)