Skip to content

Commit 7acb6d0

Browse files
authored
Merge pull request #1084 from objectstack-ai/copilot/fix-agent-chat-sse-format
2 parents 6df22a5 + c20bcde commit 7acb6d0

File tree

4 files changed

+125
-8
lines changed

4 files changed

+125
-8
lines changed

CHANGELOG.md

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

1010
### Fixed
11+
- **Agent Chat: Vercel SSE Data Stream support** — The agent chat endpoint
12+
(`/api/v1/ai/agents/:agentName/chat`) now returns Vercel AI SDK v6 UI Message Stream Protocol
13+
(SSE) by default, matching the general chat endpoint behaviour. Previously, the agent chat route
14+
only returned plain JSON, causing `DefaultChatTransport` (used by `@ai-sdk/react` `useChat`) to
15+
fail silently — the API responded correctly but the Studio AI Chat Panel rendered no content.
16+
The endpoint now uses `streamChatWithTools` + `encodeVercelDataStream` for `stream !== false`
17+
requests (the default), and falls back to JSON only when `stream: false` is explicitly set.
18+
Studio's error UI is also enhanced to surface SSE parse failures clearly instead of silent failure.
1119
- **Agent Chat: Vercel AI SDK v6 `parts` format support** — The agent chat endpoint
1220
(`/api/v1/ai/agents/:agentName/chat`) now accepts Vercel AI SDK v6 `parts`-based message
1321
format in addition to the legacy `content` string format. Previously, sending messages

apps/studio/src/components/AiChatPanel.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,8 +519,20 @@ export function AiChatPanel() {
519519
</div>
520520
)}
521521
{error && (
522-
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
523-
Error: {error.message || 'Something went wrong'}
522+
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
523+
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
524+
<div>
525+
<p className="font-medium">Chat Error</p>
526+
<p className="mt-0.5 text-xs opacity-80">
527+
{error.message || 'Something went wrong'}
528+
</p>
529+
{error.message && /unexpected|json|parse|stream/i.test(error.message) && (
530+
<p className="mt-1 text-xs opacity-70">
531+
The server may not be returning the expected Vercel AI Data Stream format.
532+
Ensure the backend endpoint supports SSE streaming.
533+
</p>
534+
)}
535+
</div>
524536
</div>
525537
)}
526538
</div>

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,13 +807,14 @@ describe('Agent Routes', () => {
807807
expect((resp.body as any).error).toContain('not active');
808808
});
809809

810-
it('should return 200 with agent response for valid request', async () => {
810+
it('should return 200 with agent response for valid request (stream=false)', async () => {
811811
const chatRoute = routes.find(r => r.method === 'POST')!;
812812
const resp = await chatRoute.handler({
813813
params: { agentName: 'data_chat' },
814814
body: {
815815
messages: [{ role: 'user', content: 'List all tables' }],
816816
context: { objectName: 'account' },
817+
stream: false,
817818
},
818819
});
819820
expect(resp.status).toBe(200);
@@ -862,6 +863,7 @@ describe('Agent Routes', () => {
862863
params: { agentName: 'data_chat' },
863864
body: {
864865
messages: [{ role: 'user', content: 'test' }],
866+
stream: false,
865867
options: {
866868
tools: [{ name: 'injected_tool', description: 'Evil', parameters: {} }],
867869
toolChoice: 'injected_tool',
@@ -882,6 +884,7 @@ describe('Agent Routes', () => {
882884
const resp = await chatRoute.handler({
883885
params: { agentName: 'data_chat' },
884886
body: {
887+
stream: false,
885888
messages: [
886889
{
887890
role: 'user',
@@ -899,6 +902,7 @@ describe('Agent Routes', () => {
899902
const resp = await chatRoute.handler({
900903
params: { agentName: 'data_chat' },
901904
body: {
905+
stream: false,
902906
messages: [
903907
{ role: 'user', content: 'Hello' },
904908
{
@@ -920,6 +924,7 @@ describe('Agent Routes', () => {
920924
const resp = await chatRoute.handler({
921925
params: { agentName: 'data_chat' },
922926
body: {
927+
stream: false,
923928
messages: [
924929
{
925930
role: 'assistant',
@@ -946,6 +951,66 @@ describe('Agent Routes', () => {
946951
expect(resp.status).toBe(400);
947952
expect((resp.body as any).error).toContain('content');
948953
});
954+
955+
// ── Vercel Data Stream Protocol (SSE) ──
956+
957+
it('should default to Vercel Data Stream mode when stream is not specified', async () => {
958+
const chatRoute = routes.find(r => r.method === 'POST')!;
959+
const resp = await chatRoute.handler({
960+
params: { agentName: 'data_chat' },
961+
body: {
962+
messages: [{ role: 'user', content: 'List all tables' }],
963+
},
964+
});
965+
expect(resp.status).toBe(200);
966+
expect(resp.stream).toBe(true);
967+
expect(resp.vercelDataStream).toBe(true);
968+
expect(resp.events).toBeDefined();
969+
970+
// Consume the Vercel Data Stream events
971+
const events: unknown[] = [];
972+
for await (const event of resp.events!) {
973+
events.push(event);
974+
}
975+
expect(events.length).toBeGreaterThan(0);
976+
// Must contain standard SSE lifecycle events
977+
const eventsStr = events.join('');
978+
expect(eventsStr).toContain('"type":"start"');
979+
expect(eventsStr).toContain('"type":"text-delta"');
980+
expect(eventsStr).toContain('"type":"finish"');
981+
expect(eventsStr).toContain('data: [DONE]');
982+
});
983+
984+
it('should return Vercel Data Stream when stream=true explicitly', async () => {
985+
const chatRoute = routes.find(r => r.method === 'POST')!;
986+
const resp = await chatRoute.handler({
987+
params: { agentName: 'data_chat' },
988+
body: {
989+
messages: [{ role: 'user', content: 'Hello agent' }],
990+
stream: true,
991+
},
992+
});
993+
expect(resp.status).toBe(200);
994+
expect(resp.stream).toBe(true);
995+
expect(resp.vercelDataStream).toBe(true);
996+
expect(resp.events).toBeDefined();
997+
});
998+
999+
it('should return JSON when stream=false', async () => {
1000+
const chatRoute = routes.find(r => r.method === 'POST')!;
1001+
const resp = await chatRoute.handler({
1002+
params: { agentName: 'data_chat' },
1003+
body: {
1004+
messages: [{ role: 'user', content: 'Hello agent' }],
1005+
stream: false,
1006+
},
1007+
});
1008+
expect(resp.status).toBe(200);
1009+
expect(resp.stream).toBeUndefined();
1010+
expect(resp.vercelDataStream).toBeUndefined();
1011+
expect(resp.body).toBeDefined();
1012+
expect((resp.body as any).content).toBeDefined();
1013+
});
9491014
});
9501015

9511016
// ═══════════════════════════════════════════════════════════════════

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { AIService } from '../ai-service.js';
66
import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
77
import type { RouteDefinition } from './ai-routes.js';
88
import { normalizeMessage, validateMessageContent } from './message-utils.js';
9+
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
910

1011
/**
1112
* Allowed message roles for the agent chat endpoint.
@@ -68,10 +69,15 @@ export function buildAgentRoutes(
6869
},
6970

7071
// ── Chat with a specific agent ──────────────────────────────
72+
//
73+
// Dual-mode endpoint matching the general chat route behaviour:
74+
// • `stream !== false` → Vercel Data Stream Protocol (SSE)
75+
// • `stream === false` → JSON response (legacy)
76+
//
7177
{
7278
method: 'POST',
7379
path: '/api/v1/ai/agents/:agentName/chat',
74-
description: 'Chat with a specific AI agent',
80+
description: 'Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)',
7581
auth: true,
7682
permissions: ['ai:chat', 'ai:agents'],
7783
handler: async (req) => {
@@ -81,11 +87,12 @@ export function buildAgentRoutes(
8187
}
8288

8389
// Parse request body
90+
const body = (req.body ?? {}) as Record<string, unknown>;
8491
const {
8592
messages: rawMessages,
8693
context: chatContext,
8794
options: extraOptions,
88-
} = (req.body ?? {}) as {
95+
} = body as {
8996
messages?: unknown[];
9097
context?: AgentChatContext;
9198
options?: Record<string, unknown>;
@@ -138,12 +145,37 @@ export function buildAgentRoutes(
138145
...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
139146
];
140147

141-
// Use chatWithTools for automatic tool resolution
142-
const result = await aiService.chatWithTools(fullMessages, {
148+
const chatWithToolsOptions = {
143149
...mergedOptions,
144150
maxIterations: agent.planning?.maxIterations,
145-
});
151+
};
146152

153+
// ── Choose response mode ─────────────────────────────
154+
const wantStream = body.stream !== false;
155+
156+
if (wantStream) {
157+
// Vercel Data Stream Protocol (SSE) — matches general chat behaviour
158+
if (!aiService.streamChatWithTools) {
159+
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
160+
}
161+
const events = aiService.streamChatWithTools(fullMessages, chatWithToolsOptions);
162+
return {
163+
status: 200,
164+
stream: true,
165+
vercelDataStream: true,
166+
contentType: 'text/event-stream',
167+
headers: {
168+
'Content-Type': 'text/event-stream',
169+
'Cache-Control': 'no-cache',
170+
'Connection': 'keep-alive',
171+
'x-vercel-ai-ui-message-stream': 'v1',
172+
},
173+
events: encodeVercelDataStream(events),
174+
};
175+
}
176+
177+
// JSON response (non-streaming / legacy)
178+
const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
147179
return { status: 200, body: result };
148180
} catch (err) {
149181
logger.error(

0 commit comments

Comments
 (0)