Skip to content

Commit 295e118

Browse files
committed
2 parents fbba408 + 00813b0 commit 295e118

5 files changed

Lines changed: 101 additions & 64 deletions

File tree

apps/studio/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## Unreleased
44

5+
### Patch Changes
6+
7+
- Remove `functions` block from `vercel.json` to fix deployment error:
8+
"The pattern 'api/index.js' defined in `functions` doesn't match any
9+
Serverless Functions inside the `api` directory."
10+
11+
The `api/index.js` file is a build artifact generated by `bundle-api.mjs`
12+
during the Vercel build step — it does not exist in the source tree.
13+
Vercel validates `functions` patterns before running the build, causing
14+
the mismatch. The per-function configuration (`memory`, `maxDuration`)
15+
is already exported from `server/index.ts` via `export const config`,
16+
which the `@vercel/node` runtime picks up at deploy time.
17+
518
### Minor Changes
619

720
- Add collapsible right-side AI Chat floating panel (VS Code Copilot Chat style).

apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@objectstack/plugin-msw": "workspace:*",
3232
"@objectstack/plugin-security": "workspace:*",
3333
"@objectstack/runtime": "workspace:*",
34+
"@objectstack/service-ai": "workspace:*",
3435
"@objectstack/service-feed": "workspace:*",
3536
"@objectstack/spec": "workspace:*",
3637
"@radix-ui/react-avatar": "^1.1.11",

apps/studio/vercel.json

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,6 @@
99
"VITE_SERVER_URL": "https://play.objectstack.ai"
1010
}
1111
},
12-
"functions": {
13-
"api/index.js": {
14-
"memory": 1024,
15-
"maxDuration": 60,
16-
"includeFiles": "node_modules/{@libsql,better-sqlite3}/**"
17-
}
18-
},
1912
"headers": [
2013
{
2114
"source": "/assets/(.*)",

packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts

Lines changed: 84 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,39 @@ import { describe, it, expect } from 'vitest';
44
import type { TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
55
import { encodeStreamPart, encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
66

7+
// Helper to parse SSE frame payload
8+
function parseSSE(frame: string): Record<string, unknown> | null {
9+
if (!frame.startsWith('data: ') || !frame.endsWith('\n\n')) return null;
10+
const json = frame.slice(6, -2);
11+
if (json === '[DONE]') return null;
12+
return JSON.parse(json);
13+
}
14+
715
// ─────────────────────────────────────────────────────────────────
8-
// encodeStreamPart — individual frame encoding
16+
// encodeStreamPart — individual frame encoding (v6 SSE format)
917
// ─────────────────────────────────────────────────────────────────
1018

1119
describe('encodeStreamPart', () => {
12-
it('should encode text-delta as "0:" frame', () => {
20+
it('should encode text-delta as SSE frame', () => {
1321
const part = { type: 'text-delta', text: 'Hello world' } as TextStreamPart<ToolSet>;
14-
expect(encodeStreamPart(part)).toBe('0:"Hello world"\n');
22+
const frame = encodeStreamPart(part);
23+
const payload = parseSSE(frame);
24+
expect(payload).toEqual({ type: 'text-delta', id: '0', delta: 'Hello world' });
1525
});
1626

1727
it('should JSON-escape text-delta content', () => {
1828
const part = { type: 'text-delta', text: 'say "hi"\nnewline' } as TextStreamPart<ToolSet>;
1929
const frame = encodeStreamPart(part);
20-
expect(frame).toBe(`0:${JSON.stringify('say "hi"\nnewline')}\n`);
21-
expect(frame.startsWith('0:')).toBe(true);
30+
expect(frame.startsWith('data: ')).toBe(true);
31+
expect(frame.endsWith('\n\n')).toBe(true);
2232

2333
// Verify round-trip: decode the frame payload back to the original text
24-
const decoded = JSON.parse(frame.slice(2).trim());
25-
expect(decoded).toBe('say "hi"\nnewline');
34+
const payload = parseSSE(frame);
35+
expect(payload).not.toBeNull();
36+
expect((payload as Record<string, unknown>).delta).toBe('say "hi"\nnewline');
2637
});
2738

28-
it('should encode tool-call as "9:" frame', () => {
39+
it('should encode tool-call as tool-input-available SSE frame', () => {
2940
const part = {
3041
type: 'tool-call',
3142
toolCallId: 'call_1',
@@ -34,51 +45,48 @@ describe('encodeStreamPart', () => {
3445
} as TextStreamPart<ToolSet>;
3546

3647
const frame = encodeStreamPart(part);
37-
expect(frame.startsWith('9:')).toBe(true);
38-
39-
const payload = JSON.parse(frame.slice(2));
48+
const payload = parseSSE(frame);
4049
expect(payload).toEqual({
50+
type: 'tool-input-available',
4151
toolCallId: 'call_1',
4252
toolName: 'get_weather',
43-
args: { location: 'San Francisco' },
53+
input: { location: 'San Francisco' },
4454
});
4555
});
4656

47-
it('should encode tool-input-start as "b:" frame', () => {
57+
it('should encode tool-input-start as SSE frame', () => {
4858
const part = {
4959
type: 'tool-input-start',
5060
id: 'call_2',
5161
toolName: 'search',
5262
} as TextStreamPart<ToolSet>;
5363

5464
const frame = encodeStreamPart(part);
55-
expect(frame.startsWith('b:')).toBe(true);
56-
57-
const payload = JSON.parse(frame.slice(2));
65+
const payload = parseSSE(frame);
5866
expect(payload).toEqual({
67+
type: 'tool-input-start',
5968
toolCallId: 'call_2',
6069
toolName: 'search',
6170
});
6271
});
6372

64-
it('should encode tool-input-delta as "c:" frame', () => {
73+
it('should encode tool-input-delta as SSE frame', () => {
6574
const part = {
6675
type: 'tool-input-delta',
6776
id: 'call_2',
6877
delta: '{"query":',
6978
} as TextStreamPart<ToolSet>;
7079

7180
const frame = encodeStreamPart(part);
72-
expect(frame.startsWith('c:')).toBe(true);
73-
74-
const payload = JSON.parse(frame.slice(2));
81+
const payload = parseSSE(frame);
7582
expect(payload).toEqual({
83+
type: 'tool-input-delta',
7684
toolCallId: 'call_2',
77-
argsTextDelta: '{"query":',
85+
inputTextDelta: '{"query":',
7886
});
7987
});
8088

81-
it('should encode tool-result as "a:" frame', () => {
89+
it('should encode tool-result as tool-output-available SSE frame', () => {
8290
const part = {
8391
type: 'tool-result',
8492
toolCallId: 'call_1',
@@ -87,43 +95,33 @@ describe('encodeStreamPart', () => {
8795
} as TextStreamPart<ToolSet>;
8896

8997
const frame = encodeStreamPart(part);
90-
expect(frame.startsWith('a:')).toBe(true);
91-
92-
const payload = JSON.parse(frame.slice(2));
98+
const payload = parseSSE(frame);
9399
expect(payload).toEqual({
100+
type: 'tool-output-available',
94101
toolCallId: 'call_1',
95-
result: { temperature: 72 },
102+
output: { temperature: 72 },
96103
});
97104
});
98105

99-
it('should encode finish as "d:" frame', () => {
106+
it('should return empty string for finish (handled by generator)', () => {
100107
const part = {
101108
type: 'finish',
102109
finishReason: 'stop',
103110
totalUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
104111
rawFinishReason: 'stop',
105112
} as unknown as TextStreamPart<ToolSet>;
106113

107-
const frame = encodeStreamPart(part);
108-
expect(frame.startsWith('d:')).toBe(true);
109-
110-
const payload = JSON.parse(frame.slice(2));
111-
expect(payload.finishReason).toBe('stop');
112-
expect(payload.usage).toEqual({ promptTokens: 10, completionTokens: 20, totalTokens: 30 });
114+
expect(encodeStreamPart(part)).toBe('');
113115
});
114116

115-
it('should encode finish-step as "e:" frame', () => {
117+
it('should return empty string for finish-step (handled by generator)', () => {
116118
const part = {
117119
type: 'finish-step',
118120
finishReason: 'tool-calls',
119121
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
120122
} as unknown as TextStreamPart<ToolSet>;
121123

122-
const frame = encodeStreamPart(part);
123-
expect(frame.startsWith('e:')).toBe(true);
124-
125-
const payload = JSON.parse(frame.slice(2));
126-
expect(payload.finishReason).toBe('tool-calls');
124+
expect(encodeStreamPart(part)).toBe('');
127125
});
128126

129127
it('should return empty string for unknown event types', () => {
@@ -133,11 +131,13 @@ describe('encodeStreamPart', () => {
133131
});
134132

135133
// ─────────────────────────────────────────────────────────────────
136-
// encodeVercelDataStream — async iterable transformation
134+
// encodeVercelDataStream — async iterable transformation (v6 SSE)
135+
//
136+
// Lifecycle: start → start-step → text-start → ...events... → text-end → finish-step → finish → [DONE]
137137
// ─────────────────────────────────────────────────────────────────
138138

139139
describe('encodeVercelDataStream', () => {
140-
it('should transform stream events into Vercel Data Stream frames', async () => {
140+
it('should transform stream events into v6 UI Message Stream frames', async () => {
141141
async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
142142
yield { type: 'text-delta', text: 'Hello' } as TextStreamPart<ToolSet>;
143143
yield { type: 'text-delta', text: ' world' } as TextStreamPart<ToolSet>;
@@ -154,10 +154,25 @@ describe('encodeVercelDataStream', () => {
154154
frames.push(frame);
155155
}
156156

157-
expect(frames).toHaveLength(3);
158-
expect(frames[0]).toBe('0:"Hello"\n');
159-
expect(frames[1]).toBe('0:" world"\n');
160-
expect(frames[2]).toMatch(/^d:/);
157+
// Preamble: start, start-step, text-start
158+
// Content: 2 text-deltas
159+
// Postamble: text-end, finish-step, finish, [DONE]
160+
expect(frames).toHaveLength(9);
161+
162+
// Preamble
163+
expect(parseSSE(frames[0])).toEqual({ type: 'start' });
164+
expect(parseSSE(frames[1])).toEqual({ type: 'start-step' });
165+
expect(parseSSE(frames[2])).toEqual({ type: 'text-start', id: '0' });
166+
167+
// Content
168+
expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hello' });
169+
expect(parseSSE(frames[4])).toMatchObject({ type: 'text-delta', delta: ' world' });
170+
171+
// Postamble
172+
expect(parseSSE(frames[5])).toEqual({ type: 'text-end', id: '0' });
173+
expect(parseSSE(frames[6])).toEqual({ type: 'finish-step' });
174+
expect(parseSSE(frames[7])).toMatchObject({ type: 'finish', finishReason: 'stop' });
175+
expect(frames[8]).toBe('data: [DONE]\n\n');
161176
});
162177

163178
it('should skip events with no wire format mapping', async () => {
@@ -177,10 +192,9 @@ describe('encodeVercelDataStream', () => {
177192
frames.push(frame);
178193
}
179194

180-
// 'unknown-internal' is silently dropped
181-
expect(frames).toHaveLength(2);
182-
expect(frames[0]).toBe('0:"Hi"\n');
183-
expect(frames[1]).toMatch(/^d:/);
195+
// Preamble(3) + 1 text-delta + Postamble(4) = 8 ('unknown-internal' dropped)
196+
expect(frames).toHaveLength(8);
197+
expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hi' });
184198
});
185199

186200
it('should handle empty stream', async () => {
@@ -193,7 +207,11 @@ describe('encodeVercelDataStream', () => {
193207
frames.push(frame);
194208
}
195209

196-
expect(frames).toHaveLength(0);
210+
// Preamble(3) + text-end + finish-step + finish + [DONE] = 7
211+
expect(frames).toHaveLength(7);
212+
expect(parseSSE(frames[0])).toEqual({ type: 'start' });
213+
expect(parseSSE(frames[3])).toEqual({ type: 'text-end', id: '0' });
214+
expect(frames[6]).toBe('data: [DONE]\n\n');
197215
});
198216

199217
it('should handle tool-call events in stream', async () => {
@@ -223,14 +241,23 @@ describe('encodeVercelDataStream', () => {
223241
frames.push(frame);
224242
}
225243

226-
expect(frames).toHaveLength(3);
227-
expect(frames[0]).toMatch(/^9:/);
228-
expect(frames[1]).toMatch(/^a:/);
229-
expect(frames[2]).toMatch(/^d:/);
244+
// Preamble(3) + tool-input-available + tool-output-available + Postamble(4) = 9
245+
expect(frames).toHaveLength(9);
230246

231247
// Verify tool-call frame content
232-
const toolCallPayload = JSON.parse(frames[0].slice(2));
233-
expect(toolCallPayload.toolCallId).toBe('call_1');
234-
expect(toolCallPayload.args).toEqual({ query: 'test' });
248+
const toolCallPayload = parseSSE(frames[3]);
249+
expect(toolCallPayload).toMatchObject({
250+
type: 'tool-input-available',
251+
toolCallId: 'call_1',
252+
toolName: 'search',
253+
input: { query: 'test' },
254+
});
255+
256+
const toolResultPayload = parseSSE(frames[4]);
257+
expect(toolResultPayload).toMatchObject({
258+
type: 'tool-output-available',
259+
toolCallId: 'call_1',
260+
output: { hits: 42 },
261+
});
235262
});
236263
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)