@bxb1337/windsurf-fast-context 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,6 @@ import { extractStrings } from '../protocol/protobuf.js';
3
3
  const TOOL_CALL_PREFIX = '[TOOL_CALLS]';
4
4
  const ARGS_PREFIX = '[ARGS]';
5
5
  const STOP_TOKEN = '</s>';
6
- const EMPTY_TOOL_CALLS_PATTERN = /TOOL_CALLS\d*(?:<\/s>)?\s*(?:\{\s*\}\s*)+/g;
7
- // OpenAI-style TOOL_CALLS format: TOOL_CALLS{"type":"function","function":{"name":3,...}}{}...
8
- const OPENAI_TOOL_CALLS_PATTERN = /^TOOL_CALLS(\{[\s\S]*\}\s*)+\s*$/;
9
6
  function parseOpenAIToolCalls(responseText) {
10
7
  if (!responseText.startsWith('TOOL_CALLS')) {
11
8
  return null;
@@ -72,20 +69,6 @@ function pushText(parts, text) {
72
69
  parts.push({ type: 'text', text });
73
70
  }
74
71
  }
75
- function extractAnswerText(args) {
76
- if (typeof args === 'string') {
77
- return args;
78
- }
79
- if (args != null && typeof args === 'object') {
80
- if ('answer' in args && typeof args.answer === 'string') {
81
- return args.answer;
82
- }
83
- if ('text' in args && typeof args.text === 'string') {
84
- return args.text;
85
- }
86
- }
87
- return JSON.stringify(args);
88
- }
89
72
  function parseStringEnd(value, startIndex) {
90
73
  let index = startIndex + 1;
91
74
  let escaping = false;
@@ -252,7 +235,6 @@ function decodeResponseText(buffer) {
252
235
  }
253
236
  export function convertResponse(buffer) {
254
237
  let responseText = decodeResponseText(buffer);
255
- responseText = responseText.replace(EMPTY_TOOL_CALLS_PATTERN, '');
256
238
  responseText = responseText.replace(STOP_TOKEN, '');
257
239
  const openaiToolCalls = parseOpenAIToolCalls(responseText);
258
240
  if (openaiToolCalls) {
@@ -283,18 +265,13 @@ export function convertResponse(buffer) {
283
265
  cursor = malformedEnd;
284
266
  continue;
285
267
  }
286
- if (toolName === 'answer') {
287
- pushText(parts, extractAnswerText(parsedArgs.parsed));
288
- }
289
- else {
290
- toolCallCount += 1;
291
- parts.push({
292
- type: 'tool-call',
293
- toolCallId: `toolcall_${toolCallCount}`,
294
- toolName,
295
- input: parsedArgs.parsed,
296
- });
297
- }
268
+ toolCallCount += 1;
269
+ parts.push({
270
+ type: 'tool-call',
271
+ toolCallId: `toolcall_${toolCallCount}`,
272
+ toolName,
273
+ input: parsedArgs.parsed,
274
+ });
298
275
  cursor = parsedArgs.endIndex;
299
276
  }
300
277
  return parts;
@@ -19,7 +19,12 @@ describe('convertResponse', () => {
19
19
  input: { query: 'prompt converter' },
20
20
  },
21
21
  { type: 'text', text: ' between ' },
22
- { type: 'text', text: 'final answer' },
22
+ {
23
+ type: 'tool-call',
24
+ toolCallId: 'toolcall_2',
25
+ toolName: 'answer',
26
+ input: { answer: 'final answer' },
27
+ },
23
28
  { type: 'text', text: ' after' },
24
29
  ]);
25
30
  });
@@ -46,7 +51,14 @@ describe('convertResponse', () => {
46
51
  payload.writeVarint(1, 150);
47
52
  payload.writeString(2, '[TOOL_CALLS]answer[ARGS]{"answer":"final answer"}');
48
53
  const result = convertResponse(payload.toBuffer());
49
- expect(result).toEqual([{ type: 'text', text: 'final answer' }]);
54
+ expect(result).toEqual([
55
+ {
56
+ type: 'tool-call',
57
+ toolCallId: 'toolcall_1',
58
+ toolName: 'answer',
59
+ input: { answer: 'final answer' },
60
+ },
61
+ ]);
50
62
  });
51
63
  it('protobuf payload - ignores metadata strings and keeps main text field', () => {
52
64
  const payload = new ProtobufEncoder();
@@ -60,76 +72,11 @@ describe('convertResponse', () => {
60
72
  const result = convertResponse(compressed);
61
73
  expect(result).toEqual([{ type: 'text', text: 'hello from gzip' }]);
62
74
  });
63
- it('strips empty TOOL_CALLS markers with stop token', () => {
64
- const input = 'Hello world TOOL_CALLS0</s>{}';
65
- const result = convertResponse(Buffer.from(input, 'utf8'));
66
- expect(result).toEqual([{ type: 'text', text: 'Hello world ' }]);
67
- });
68
- it('strips empty TOOL_CALLS markers without stop token', () => {
69
- const input = 'Hello world TOOL_CALLS1{}';
70
- const result = convertResponse(Buffer.from(input, 'utf8'));
71
- expect(result).toEqual([{ type: 'text', text: 'Hello world ' }]);
72
- });
73
- it('strips empty TOOL_CALLS markers with whitespace', () => {
74
- const input = 'Text TOOL_CALLS2{ } more text';
75
- const result = convertResponse(Buffer.from(input, 'utf8'));
76
- expect(result).toEqual([{ type: 'text', text: 'Text more text' }]);
77
- });
78
75
  it('strips standalone stop token', () => {
79
76
  const input = 'Hello world</s>';
80
77
  const result = convertResponse(Buffer.from(input, 'utf8'));
81
78
  expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
82
79
  });
83
- it('handles TOOL_CALLS with number prefix before stop token', () => {
84
- const input = 'Text before TOOL_CALLS1</s>{} text after';
85
- const result = convertResponse(Buffer.from(input, 'utf8'));
86
- expect(result).toEqual([{ type: 'text', text: 'Text before text after' }]);
87
- });
88
- it('strips TOOL_CALLS with double empty braces', () => {
89
- const input = 'Hello world TOOL_CALLS1{}{}';
90
- const result = convertResponse(Buffer.from(input, 'utf8'));
91
- expect(result).toEqual([{ type: 'text', text: 'Hello world ' }]);
92
- });
93
- it('strips TOOL_CALLS with triple empty braces', () => {
94
- const input = 'Response TOOL_CALLS2{}{}{} end';
95
- const result = convertResponse(Buffer.from(input, 'utf8'));
96
- expect(result).toEqual([{ type: 'text', text: 'Response end' }]);
97
- });
98
- it('strips TOOL_CALLS with stop token and multiple braces', () => {
99
- const input = 'Text TOOL_CALLS0</s>{}{} more text';
100
- const result = convertResponse(Buffer.from(input, 'utf8'));
101
- expect(result).toEqual([{ type: 'text', text: 'Text more text' }]);
102
- });
103
- it('handles empty marker followed by real tool call', () => {
104
- const input = 'TOOL_CALLS0{} [TOOL_CALLS]searchDocs[ARGS]{"query":"test"}';
105
- const result = convertResponse(Buffer.from(input, 'utf8'));
106
- expect(result).toEqual([
107
- {
108
- type: 'tool-call',
109
- toolCallId: 'toolcall_1',
110
- toolName: 'searchDocs',
111
- input: { query: 'test' },
112
- },
113
- ]);
114
- });
115
- it('handles real tool call followed by empty marker', () => {
116
- const input = '[TOOL_CALLS]searchDocs[ARGS]{"query":"test"} TOOL_CALLS1{} done';
117
- const result = convertResponse(Buffer.from(input, 'utf8'));
118
- expect(result).toEqual([
119
- {
120
- type: 'tool-call',
121
- toolCallId: 'toolcall_1',
122
- toolName: 'searchDocs',
123
- input: { query: 'test' },
124
- },
125
- { type: 'text', text: ' done' },
126
- ]);
127
- });
128
- it('handles empty marker adjacent to real tool call', () => {
129
- const input = 'TOOL_CALLS0{}[TOOL_CALLS]answer[ARGS]{"answer":"result"}';
130
- const result = convertResponse(Buffer.from(input, 'utf8'));
131
- expect(result).toEqual([{ type: 'text', text: 'result' }]);
132
- });
133
80
  it('parses OpenAI-style TOOL_CALLS with numeric IDs', () => {
134
81
  const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"file_path":"/home/test","search_pattern":"binance"}}}{}';
135
82
  const result = convertResponse(Buffer.from(input, 'utf8'));
@@ -184,4 +131,16 @@ describe('convertResponse', () => {
184
131
  },
185
132
  ]);
186
133
  });
134
+ it('handles OpenAI-style with string name "answer" - emits as tool-call', () => {
135
+ const input = 'TOOL_CALLS{"type":"function","function":{"name":"answer","parameters":{"answer":"final answer"}}}{}';
136
+ const result = convertResponse(Buffer.from(input, 'utf8'));
137
+ expect(result).toEqual([
138
+ {
139
+ type: 'tool-call',
140
+ toolCallId: 'toolcall_1',
141
+ toolName: 'answer',
142
+ input: { answer: 'final answer' },
143
+ },
144
+ ]);
145
+ });
187
146
  });
@@ -53,12 +53,18 @@ export class DevstralLanguageModel {
53
53
  const requestFrame = connectFrameEncode(requestPayload);
54
54
  const headers = createConnectHeaders(this.headers);
55
55
  const responseFrame = await this.transport.postUnary(`${this.baseURL}${API_SERVICE_PATH}${DEVSTRAL_STREAM_PATH}`, requestFrame, headers);
56
- const responsePayloads = connectFrameDecode(responseFrame);
56
+ const { payloads: responsePayloads } = connectFrameDecode(responseFrame);
57
57
  const payloads = responsePayloads.length > 0 ? responsePayloads : [responseFrame];
58
58
  const content = payloads.flatMap((payload) => toV3Content(convertResponse(payload)));
59
+ const unified = content.some((part) => part.type === 'tool-call')
60
+ ? 'tool-calls'
61
+ : 'stop';
59
62
  return {
60
63
  content,
61
- finishReason: 'stop',
64
+ finishReason: {
65
+ unified,
66
+ raw: undefined,
67
+ },
62
68
  usage: emptyUsage(),
63
69
  warnings: [],
64
70
  };
@@ -86,6 +92,7 @@ export class DevstralLanguageModel {
86
92
  options.abortSignal?.addEventListener('abort', abortHandler, { once: true });
87
93
  let textSegmentId = null;
88
94
  let textSegmentCounter = 0;
95
+ let hasToolCalls = false;
89
96
  let pending = Buffer.alloc(0);
90
97
  const closeTextSegment = () => {
91
98
  if (textSegmentId == null) {
@@ -100,7 +107,7 @@ export class DevstralLanguageModel {
100
107
  try {
101
108
  safeEnqueue(controller, { type: 'stream-start', warnings: [] });
102
109
  safeEnqueue(controller, { type: 'response-metadata', modelId: this.modelId });
103
- while (!isAborted(options.abortSignal)) {
110
+ outerLoop: while (!isAborted(options.abortSignal)) {
104
111
  const next = await reader.read();
105
112
  if (next.done) {
106
113
  break;
@@ -135,6 +142,7 @@ export class DevstralLanguageModel {
135
142
  continue;
136
143
  }
137
144
  closeTextSegment();
145
+ hasToolCalls = true;
138
146
  safeEnqueue(controller, {
139
147
  type: 'tool-input-start',
140
148
  id: part.toolCallId,
@@ -151,6 +159,9 @@ export class DevstralLanguageModel {
151
159
  });
152
160
  safeEnqueue(controller, part);
153
161
  }
162
+ if (frameResult.isEndStream) {
163
+ break outerLoop;
164
+ }
154
165
  }
155
166
  }
156
167
  if (isAborted(options.abortSignal)) {
@@ -158,10 +169,13 @@ export class DevstralLanguageModel {
158
169
  return;
159
170
  }
160
171
  closeTextSegment();
172
+ const unified = hasToolCalls ? 'tool-calls' : 'stop';
161
173
  safeEnqueue(controller, {
162
174
  type: 'finish',
163
- finishReason: 'stop',
164
- rawFinishReason: 'stop',
175
+ finishReason: {
176
+ unified,
177
+ raw: undefined,
178
+ },
165
179
  usage: emptyUsage(),
166
180
  });
167
181
  safeClose(controller);
@@ -175,8 +189,10 @@ export class DevstralLanguageModel {
175
189
  });
176
190
  safeEnqueue(controller, {
177
191
  type: 'finish',
178
- finishReason: 'error',
179
- rawFinishReason: 'error',
192
+ finishReason: {
193
+ unified: 'error',
194
+ raw: undefined,
195
+ },
180
196
  usage: emptyUsage(),
181
197
  });
182
198
  }
@@ -202,10 +218,11 @@ function readNextConnectFrame(buffer) {
202
218
  return null;
203
219
  }
204
220
  const frame = buffer.subarray(0, frameLength);
205
- const decoded = connectFrameDecode(frame);
221
+ const { payloads, isEndStream } = connectFrameDecode(frame);
206
222
  return {
207
- payload: decoded[0] ?? Buffer.alloc(0),
223
+ payload: payloads[0] ?? Buffer.alloc(0),
208
224
  rest: buffer.subarray(frameLength),
225
+ isEndStream,
209
226
  };
210
227
  }
211
228
  function safeEnqueue(controller, part) {
@@ -249,31 +266,35 @@ function emptyUsage() {
249
266
  }
250
267
  function toV3Content(parts) {
251
268
  return parts.map((part) => {
252
- if (part.type === 'tool-call') {
253
- return {
254
- type: 'tool-call',
255
- toolCallId: part.toolCallId,
256
- toolName: part.toolName,
257
- input: JSON.stringify(part.input),
258
- };
269
+ if (part.type !== 'tool-call') {
270
+ return part;
259
271
  }
260
- return part;
272
+ const input = typeof part.input === 'string' ? part.input : JSON.stringify(part.input);
273
+ return {
274
+ type: 'tool-call',
275
+ toolCallId: part.toolCallId,
276
+ toolName: part.toolName,
277
+ input,
278
+ };
261
279
  });
262
280
  }
281
+ function isFunctionTool(tool) {
282
+ return tool.type === 'function';
283
+ }
263
284
  function buildGenerateRequest(input) {
264
285
  const request = new ProtobufEncoder();
265
286
  request.writeMessage(1, buildMetadata(input.apiKey, input.jwt));
266
287
  for (const message of input.messages) {
267
288
  request.writeMessage(2, buildMessage(message));
268
289
  }
269
- // Tools are sent as a single JSON string at field 3 (not repeated messages)
270
- if (input.tools && Object.keys(input.tools).length > 0) {
271
- const toolsArray = Object.entries(input.tools).map(([name, tool]) => ({
290
+ const functionTools = input.tools?.filter(isFunctionTool) ?? [];
291
+ if (functionTools.length > 0) {
292
+ const toolsArray = functionTools.map((tool) => ({
272
293
  type: 'function',
273
294
  function: {
274
- name,
295
+ name: tool.name,
275
296
  description: tool.description ?? '',
276
- parameters: tool.parameters ?? {},
297
+ parameters: tool.inputSchema,
277
298
  },
278
299
  }));
279
300
  request.writeString(3, JSON.stringify(toolsArray));
@@ -64,8 +64,11 @@ function waitFor(ms) {
64
64
  }
65
65
  function decodeRequestPayload(body) {
66
66
  // Body is now a connect frame directly (gzip is inside the frame, not outside)
67
- const decodedFrames = connectFrameDecode(body);
68
- return decodedFrames[0] ?? Buffer.alloc(0);
67
+ const { payloads } = connectFrameDecode(body);
68
+ return payloads[0] ?? Buffer.alloc(0);
69
+ }
70
+ function extractToolsPayload(strings) {
71
+ return strings.find((value) => value.startsWith('[{"type":"function"'));
69
72
  }
70
73
  async function collectStreamParts(stream) {
71
74
  const reader = stream.getReader();
@@ -134,7 +137,7 @@ describe('DevstralLanguageModel doGenerate', () => {
134
137
  prompt: [{ role: 'user', content: [{ type: 'text', text: 'Find auth logic.' }] }],
135
138
  });
136
139
  expect(result.content).toEqual([{ type: 'text', text: 'generated answer' }]);
137
- expect(result.finishReason).toBe('stop');
140
+ expect(result.finishReason).toEqual({ unified: 'stop', raw: undefined });
138
141
  expect(result.usage).toEqual({
139
142
  inputTokens: {
140
143
  total: undefined,
@@ -157,7 +160,7 @@ describe('DevstralLanguageModel doGenerate', () => {
157
160
  expect(combined).toContain(jwt);
158
161
  expect(combined).toContain('Find auth logic.');
159
162
  });
160
- it('generate request with tool markers returns tool-call content parts', async () => {
163
+ it('doGenerate accepts function tools array with inputSchema', async () => {
161
164
  const requestBodies = [];
162
165
  const jwt = makeJwt(4_200_000_000, 'tools');
163
166
  const fakeFetch = async (input, init) => {
@@ -172,10 +175,12 @@ describe('DevstralLanguageModel doGenerate', () => {
172
175
  const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
173
176
  const result = await model.doGenerate({
174
177
  prompt: [{ role: 'user', content: [{ type: 'text', text: 'Inspect jwt manager.' }] }],
175
- tools: {
176
- searchRepo: {
178
+ tools: [
179
+ {
180
+ type: 'function',
181
+ name: 'searchRepo',
177
182
  description: 'Search repository files',
178
- parameters: {
183
+ inputSchema: {
179
184
  type: 'object',
180
185
  properties: {
181
186
  query: { type: 'string' },
@@ -183,7 +188,7 @@ describe('DevstralLanguageModel doGenerate', () => {
183
188
  required: ['query'],
184
189
  },
185
190
  },
186
- },
191
+ ],
187
192
  });
188
193
  expect(result.content).toEqual([
189
194
  {
@@ -193,12 +198,88 @@ describe('DevstralLanguageModel doGenerate', () => {
193
198
  input: '{"query":"jwt manager"}',
194
199
  },
195
200
  ]);
201
+ expect(result.finishReason).toEqual({ unified: 'tool-calls', raw: undefined });
196
202
  const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
197
- const combined = strings.join('\n');
198
- expect(combined).toContain('searchRepo');
199
- expect(combined).toContain('Search repository files');
200
- expect(combined).toContain('Inspect jwt manager.');
201
- expect(combined).toContain('query');
203
+ const toolsPayload = extractToolsPayload(strings);
204
+ expect(toolsPayload).toBeDefined();
205
+ const parsedTools = JSON.parse(toolsPayload ?? '[]');
206
+ expect(parsedTools).toEqual([
207
+ {
208
+ type: 'function',
209
+ function: {
210
+ name: 'searchRepo',
211
+ description: 'Search repository files',
212
+ parameters: {
213
+ type: 'object',
214
+ properties: {
215
+ query: { type: 'string' },
216
+ },
217
+ required: ['query'],
218
+ },
219
+ },
220
+ },
221
+ ]);
222
+ });
223
+ it('filters provider tools from tools array serialization', async () => {
224
+ const requestBodies = [];
225
+ const jwt = makeJwt(4_200_000_001, 'provider-tools');
226
+ const fakeFetch = async (input, init) => {
227
+ const url = String(input);
228
+ if (url.endsWith('/GetUserJwt')) {
229
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
230
+ }
231
+ requestBodies.push(bufferFromBody(init?.body));
232
+ return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
233
+ };
234
+ const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
235
+ await model.doGenerate({
236
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Filter provider tools.' }] }],
237
+ tools: [
238
+ {
239
+ type: 'function',
240
+ name: 'searchRepo',
241
+ description: 'Search repository files',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ query: { type: 'string' },
246
+ },
247
+ required: ['query'],
248
+ },
249
+ },
250
+ {
251
+ type: 'provider',
252
+ id: 'windsurf.restricted_exec',
253
+ name: 'restricted_exec',
254
+ args: { mode: 'read-only' },
255
+ },
256
+ ],
257
+ });
258
+ const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
259
+ const toolsPayload = extractToolsPayload(strings);
260
+ expect(toolsPayload).toBeDefined();
261
+ const parsedTools = JSON.parse(toolsPayload ?? '[]');
262
+ expect(parsedTools).toHaveLength(1);
263
+ expect(parsedTools[0]).toMatchObject({ function: { name: 'searchRepo' } });
264
+ });
265
+ it('handles empty tools array without serializing tool payload', async () => {
266
+ const requestBodies = [];
267
+ const jwt = makeJwt(4_200_000_002, 'empty-tools');
268
+ const fakeFetch = async (input, init) => {
269
+ const url = String(input);
270
+ if (url.endsWith('/GetUserJwt')) {
271
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
272
+ }
273
+ requestBodies.push(bufferFromBody(init?.body));
274
+ return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
275
+ };
276
+ const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
277
+ await model.doGenerate({
278
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'No tools this turn.' }] }],
279
+ tools: [],
280
+ });
281
+ const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
282
+ expect(extractToolsPayload(strings)).toBeUndefined();
202
283
  });
203
284
  });
204
285
  describe('DevstralLanguageModel doStream', () => {
@@ -287,8 +368,10 @@ describe('DevstralLanguageModel doStream', () => {
287
368
  expect(parts[4]).toMatchObject({ type: 'text-delta', delta: 'world' });
288
369
  expect(parts[6]).toEqual({
289
370
  type: 'finish',
290
- finishReason: 'stop',
291
- rawFinishReason: 'stop',
371
+ finishReason: {
372
+ unified: 'stop',
373
+ raw: undefined,
374
+ },
292
375
  usage: {
293
376
  inputTokens: {
294
377
  total: undefined,
@@ -329,10 +412,12 @@ describe('DevstralLanguageModel doStream', () => {
329
412
  });
330
413
  const result = await model.doStream({
331
414
  prompt: [{ role: 'user', content: [{ type: 'text', text: 'Call tools.' }] }],
332
- tools: {
333
- searchRepo: {
415
+ tools: [
416
+ {
417
+ type: 'function',
418
+ name: 'searchRepo',
334
419
  description: 'Search repository files',
335
- parameters: {
420
+ inputSchema: {
336
421
  type: 'object',
337
422
  properties: {
338
423
  query: { type: 'string' },
@@ -340,7 +425,7 @@ describe('DevstralLanguageModel doStream', () => {
340
425
  required: ['query'],
341
426
  },
342
427
  },
343
- },
428
+ ],
344
429
  });
345
430
  const parts = await collectStreamParts(result.stream);
346
431
  expect(parts.map((part) => part.type)).toEqual([
@@ -359,6 +444,96 @@ describe('DevstralLanguageModel doStream', () => {
359
444
  toolName: 'searchRepo',
360
445
  input: '{"query":"jwt manager"}',
361
446
  });
447
+ expect(parts[6]).toEqual({
448
+ type: 'finish',
449
+ finishReason: {
450
+ unified: 'tool-calls',
451
+ raw: undefined,
452
+ },
453
+ usage: {
454
+ inputTokens: {
455
+ total: undefined,
456
+ noCache: undefined,
457
+ cacheRead: undefined,
458
+ cacheWrite: undefined,
459
+ },
460
+ outputTokens: {
461
+ total: undefined,
462
+ text: undefined,
463
+ reasoning: undefined,
464
+ },
465
+ },
466
+ });
467
+ });
468
+ it('stream terminates immediately on EndStreamResponse frame (flags=2)', async () => {
469
+ const jwt = makeJwt(4_300_000_003, 'end-stream-response');
470
+ const textFrame = connectFrameEncode(Buffer.from('hello world', 'utf8'));
471
+ const endStreamPayload = Buffer.alloc(0);
472
+ const endStreamFrame = Buffer.allocUnsafe(5 + endStreamPayload.length);
473
+ endStreamFrame.writeUInt8(2, 0);
474
+ endStreamFrame.writeUInt32BE(endStreamPayload.length, 1);
475
+ endStreamPayload.copy(endStreamFrame, 5);
476
+ let releaseHttpClose;
477
+ const waitForHttpClose = new Promise((resolve) => {
478
+ releaseHttpClose = resolve;
479
+ });
480
+ const fakeFetch = async (input) => {
481
+ const url = String(input);
482
+ if (url.endsWith('/GetUserJwt')) {
483
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
484
+ }
485
+ let sentTextFrame = false;
486
+ let sentEndStreamFrame = false;
487
+ return new Response(new ReadableStream({
488
+ async pull(controller) {
489
+ if (!sentTextFrame) {
490
+ controller.enqueue(Uint8Array.from(textFrame));
491
+ sentTextFrame = true;
492
+ return;
493
+ }
494
+ if (!sentEndStreamFrame) {
495
+ controller.enqueue(Uint8Array.from(endStreamFrame));
496
+ sentEndStreamFrame = true;
497
+ return;
498
+ }
499
+ await waitForHttpClose;
500
+ controller.close();
501
+ },
502
+ }), { status: 200 });
503
+ };
504
+ const model = new DevstralLanguageModel({
505
+ apiKey: 'end-stream-key',
506
+ fetch: fakeFetch,
507
+ baseURL: 'https://windsurf.test',
508
+ });
509
+ const result = await model.doStream({
510
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Stream with end.' }] }],
511
+ });
512
+ const reader = result.stream.getReader();
513
+ const parts = [];
514
+ const resultType = await Promise.race([
515
+ (async () => {
516
+ while (true) {
517
+ const next = await reader.read();
518
+ if (next.done) {
519
+ break;
520
+ }
521
+ parts.push(next.value);
522
+ }
523
+ return 'completed';
524
+ })(),
525
+ waitFor(100).then(() => 'timed-out'),
526
+ ]);
527
+ expect(resultType).toBe('completed');
528
+ expect(parts.map((p) => p.type)).toEqual([
529
+ 'stream-start',
530
+ 'response-metadata',
531
+ 'text-start',
532
+ 'text-delta',
533
+ 'text-end',
534
+ 'finish',
535
+ ]);
536
+ releaseHttpClose?.();
362
537
  });
363
538
  it('abort stops stream mid-response', async () => {
364
539
  const controller = new AbortController();
@@ -1,6 +1,7 @@
1
1
  import { gunzipSync, gzipSync } from 'node:zlib';
2
2
  const FRAME_HEADER_SIZE = 5;
3
- const FLAG_COMPRESSED = 1;
3
+ export const FLAG_COMPRESSED = 1;
4
+ export const FLAG_END_STREAM = 2;
4
5
  /**
5
6
  * Encode a protobuf payload into a Connect-RPC frame.
6
7
  *
@@ -20,6 +21,7 @@ export function connectFrameEncode(payload, compress = true) {
20
21
  export function connectFrameDecode(buffer) {
21
22
  const decoded = [];
22
23
  let offset = 0;
24
+ let isEndStream = false;
23
25
  while (offset + FRAME_HEADER_SIZE <= buffer.length) {
24
26
  const flags = buffer.readUInt8(offset);
25
27
  const payloadLength = buffer.readUInt32BE(offset + 1);
@@ -29,8 +31,11 @@ export function connectFrameDecode(buffer) {
29
31
  break;
30
32
  }
31
33
  const payload = buffer.subarray(payloadStart, payloadEnd);
32
- decoded.push(flags === FLAG_COMPRESSED ? gunzipSync(payload) : Buffer.from(payload));
34
+ decoded.push((flags & FLAG_COMPRESSED) !== 0 ? gunzipSync(payload) : Buffer.from(payload));
35
+ if ((flags & FLAG_END_STREAM) !== 0) {
36
+ isEndStream = true;
37
+ }
33
38
  offset = payloadEnd;
34
39
  }
35
- return decoded;
40
+ return { payloads: decoded, isEndStream };
36
41
  }