@bxb1337/windsurf-fast-context 1.1.0 → 1.1.1

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,192 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.convertResponse = convertResponse;
4
4
  const node_zlib_1 = require("node:zlib");
5
5
  const protobuf_js_1 = require("../protocol/protobuf.js");
6
- const TOOL_CALL_PREFIX = '[TOOL_CALLS]';
7
- const ARGS_PREFIX = '[ARGS]';
8
6
  const STOP_TOKEN = '</s>';
9
- function parseOpenAIToolCalls(responseText) {
10
- if (!responseText.startsWith('TOOL_CALLS')) {
7
+ function parseStrictOpenAIToolCalls(responseText) {
8
+ const trimmed = responseText.trim();
9
+ if (!trimmed.startsWith('['))
11
10
  return null;
11
+ try {
12
+ const parsed = JSON.parse(trimmed);
13
+ if (!Array.isArray(parsed) || parsed.length === 0)
14
+ return null;
15
+ const toolCalls = [];
16
+ for (let i = 0; i < parsed.length; i++) {
17
+ const item = parsed[i];
18
+ if (item.type !== 'function' || !item.function?.name)
19
+ continue;
20
+ toolCalls.push({
21
+ type: 'tool-call',
22
+ toolCallId: `toolcall_${i + 1}`,
23
+ toolName: String(item.function.name),
24
+ input: item.function.parameters ?? {},
25
+ });
26
+ }
27
+ return toolCalls.length > 0 ? toolCalls : null;
12
28
  }
13
- const jsonPart = responseText.slice('TOOL_CALLS'.length);
14
- if (!jsonPart.startsWith('{')) {
15
- return null;
16
- }
17
- const toolCalls = [];
18
- let cursor = 0;
19
- while (cursor < jsonPart.length) {
20
- if (jsonPart[cursor] !== '{') {
21
- cursor++;
22
- continue;
23
- }
24
- const endResult = parseBalancedEnd(jsonPart, cursor);
25
- if (endResult == null) {
26
- break;
27
- }
28
- const jsonStr = jsonPart.slice(cursor, endResult);
29
- cursor = endResult;
30
- if (jsonStr === '{}') {
31
- continue;
32
- }
33
- try {
34
- const parsed = JSON.parse(jsonStr);
35
- if (parsed.type === 'function' && parsed.function) {
36
- toolCalls.push(parsed);
37
- }
38
- }
39
- catch {
40
- continue;
41
- }
42
- }
43
- if (toolCalls.length === 0) {
29
+ catch {
44
30
  return null;
45
31
  }
46
- return toolCalls.map((call, index) => {
47
- const toolId = call.function.name;
48
- const toolName = typeof toolId === 'number' ? mapToolIdToName(toolId) : String(toolId);
49
- const args = call.function.parameters ?? {};
50
- return {
51
- type: 'tool-call',
52
- toolCallId: `toolcall_${index + 1}`,
53
- toolName,
54
- input: args,
55
- };
56
- });
57
- }
58
- function mapToolIdToName(id) {
59
- switch (id) {
60
- case 1:
61
- return 'read';
62
- case 2:
63
- return 'glob';
64
- case 3:
65
- return 'grep';
66
- default:
67
- return `tool_${id}`;
68
- }
69
32
  }
70
33
  function pushText(parts, text) {
71
34
  if (text.length > 0) {
72
35
  parts.push({ type: 'text', text });
73
36
  }
74
37
  }
75
- function parseStringEnd(value, startIndex) {
76
- let index = startIndex + 1;
77
- let escaping = false;
78
- while (index < value.length) {
79
- const char = value[index];
80
- if (escaping) {
81
- escaping = false;
82
- }
83
- else if (char === '\\') {
84
- escaping = true;
85
- }
86
- else if (char === '"') {
87
- return index + 1;
88
- }
89
- index += 1;
90
- }
91
- return null;
92
- }
93
- function parseBalancedEnd(value, startIndex) {
94
- const stack = [value[startIndex] === '{' ? '}' : ']'];
95
- let index = startIndex + 1;
96
- let inString = false;
97
- let escaping = false;
98
- while (index < value.length) {
99
- const char = value[index];
100
- if (inString) {
101
- if (escaping) {
102
- escaping = false;
103
- }
104
- else if (char === '\\') {
105
- escaping = true;
106
- }
107
- else if (char === '"') {
108
- inString = false;
109
- }
110
- index += 1;
111
- continue;
112
- }
113
- if (char === '"') {
114
- inString = true;
115
- index += 1;
116
- continue;
117
- }
118
- if (char === '{') {
119
- stack.push('}');
120
- index += 1;
121
- continue;
122
- }
123
- if (char === '[') {
124
- stack.push(']');
125
- index += 1;
126
- continue;
127
- }
128
- if (char === '}' || char === ']') {
129
- const expected = stack[stack.length - 1];
130
- if (expected !== char) {
131
- return null;
132
- }
133
- stack.pop();
134
- index += 1;
135
- if (stack.length === 0) {
136
- return index;
137
- }
138
- continue;
139
- }
140
- index += 1;
141
- }
142
- return null;
143
- }
144
- function parsePrimitiveEnd(value, startIndex) {
145
- let index = startIndex;
146
- while (index < value.length) {
147
- const char = value[index];
148
- if (char === ' ' || char === '\n' || char === '\r' || char === '\t') {
149
- break;
150
- }
151
- index += 1;
152
- }
153
- return index;
154
- }
155
- function parseJsonValue(value, startIndex) {
156
- let jsonStart = startIndex;
157
- while (jsonStart < value.length) {
158
- const char = value[jsonStart];
159
- if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') {
160
- break;
161
- }
162
- jsonStart += 1;
163
- }
164
- if (jsonStart >= value.length) {
165
- return null;
166
- }
167
- const firstChar = value[jsonStart];
168
- let endIndex;
169
- if (firstChar === '{' || firstChar === '[') {
170
- endIndex = parseBalancedEnd(value, jsonStart);
171
- }
172
- else if (firstChar === '"') {
173
- endIndex = parseStringEnd(value, jsonStart);
174
- }
175
- else {
176
- endIndex = parsePrimitiveEnd(value, jsonStart);
177
- }
178
- if (endIndex == null || endIndex <= jsonStart) {
179
- return null;
180
- }
181
- const rawJson = value.slice(jsonStart, endIndex);
182
- try {
183
- return {
184
- parsed: JSON.parse(rawJson),
185
- endIndex,
186
- };
187
- }
188
- catch {
189
- return null;
190
- }
191
- }
192
38
  function hasControlChars(value) {
193
39
  for (const char of value) {
194
40
  const code = char.charCodeAt(0);
@@ -216,10 +62,6 @@ function isLikelyMetadata(value) {
216
62
  return /^[A-Za-z0-9._:-]{1,32}$/.test(value);
217
63
  }
218
64
  function pickBestExtractedText(values) {
219
- const markerValues = values.filter((value) => value.includes(TOOL_CALL_PREFIX) || value.includes(ARGS_PREFIX));
220
- if (markerValues.length > 0) {
221
- return markerValues.join('');
222
- }
223
65
  const nonMetadata = values.filter((value) => !isLikelyMetadata(value));
224
66
  const candidates = nonMetadata.length > 0 ? nonMetadata : values;
225
67
  return candidates.reduce((best, current) => (current.length > best.length ? current : best), candidates[0] ?? '');
@@ -239,43 +81,10 @@ function decodeResponseText(buffer) {
239
81
  function convertResponse(buffer) {
240
82
  let responseText = decodeResponseText(buffer);
241
83
  responseText = responseText.replace(STOP_TOKEN, '');
242
- const openaiToolCalls = parseOpenAIToolCalls(responseText);
243
- if (openaiToolCalls) {
244
- return openaiToolCalls;
245
- }
84
+ const strictToolCalls = parseStrictOpenAIToolCalls(responseText);
85
+ if (strictToolCalls)
86
+ return strictToolCalls;
246
87
  const parts = [];
247
- let cursor = 0;
248
- let toolCallCount = 0;
249
- while (cursor < responseText.length) {
250
- const markerStart = responseText.indexOf(TOOL_CALL_PREFIX, cursor);
251
- if (markerStart === -1) {
252
- pushText(parts, responseText.slice(cursor));
253
- break;
254
- }
255
- pushText(parts, responseText.slice(cursor, markerStart));
256
- const toolNameStart = markerStart + TOOL_CALL_PREFIX.length;
257
- const argsStart = responseText.indexOf(ARGS_PREFIX, toolNameStart);
258
- if (argsStart === -1) {
259
- pushText(parts, responseText.slice(markerStart));
260
- break;
261
- }
262
- const toolName = responseText.slice(toolNameStart, argsStart);
263
- const parsedArgs = parseJsonValue(responseText, argsStart + ARGS_PREFIX.length);
264
- if (parsedArgs == null) {
265
- const nextMarker = responseText.indexOf(TOOL_CALL_PREFIX, markerStart + TOOL_CALL_PREFIX.length);
266
- const malformedEnd = nextMarker === -1 ? responseText.length : nextMarker;
267
- pushText(parts, responseText.slice(markerStart, malformedEnd));
268
- cursor = malformedEnd;
269
- continue;
270
- }
271
- toolCallCount += 1;
272
- parts.push({
273
- type: 'tool-call',
274
- toolCallId: `toolcall_${toolCallCount}`,
275
- toolName,
276
- input: parsedArgs.parsed,
277
- });
278
- cursor = parsedArgs.endIndex;
279
- }
88
+ pushText(parts, responseText);
280
89
  return parts;
281
90
  }
@@ -9,38 +9,6 @@ const response_converter_js_1 = require("./response-converter.js");
9
9
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from('plain response text', 'utf8'));
10
10
  (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: 'plain response text' }]);
11
11
  });
12
- (0, vitest_1.it)('tool-call - parses tool markers and preserves surrounding text', () => {
13
- const input = 'Before [TOOL_CALLS]searchDocs[ARGS]{"query":"prompt converter"} between [TOOL_CALLS]answer[ARGS]{"answer":"final answer"} after';
14
- const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
15
- (0, vitest_1.expect)(result).toEqual([
16
- { type: 'text', text: 'Before ' },
17
- {
18
- type: 'tool-call',
19
- toolCallId: 'toolcall_1',
20
- toolName: 'searchDocs',
21
- input: { query: 'prompt converter' },
22
- },
23
- { type: 'text', text: ' between ' },
24
- {
25
- type: 'tool-call',
26
- toolCallId: 'toolcall_2',
27
- toolName: 'answer',
28
- input: { answer: 'final answer' },
29
- },
30
- { type: 'text', text: ' after' },
31
- ]);
32
- });
33
- (0, vitest_1.it)('malformed - invalid marker json remains text and does not throw', () => {
34
- const input = 'prefix [TOOL_CALLS]searchDocs[ARGS]{"query": nope} suffix';
35
- (0, vitest_1.expect)(() => (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'))).not.toThrow();
36
- const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
37
- const combinedText = result
38
- .filter((part) => part.type === 'text')
39
- .map((part) => part.text)
40
- .join('');
41
- (0, vitest_1.expect)(result.every((part) => part.type === 'text')).toBe(true);
42
- (0, vitest_1.expect)(combinedText).toBe(input);
43
- });
44
12
  (0, vitest_1.it)('protobuf payload - extracts clean utf8 text without binary mojibake prefix', () => {
45
13
  const payload = new protobuf_js_1.ProtobufEncoder();
46
14
  payload.writeVarint(1, 150);
@@ -48,20 +16,6 @@ const response_converter_js_1 = require("./response-converter.js");
48
16
  const result = (0, response_converter_js_1.convertResponse)(payload.toBuffer());
49
17
  (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '你好,TypeScript' }]);
50
18
  });
51
- (0, vitest_1.it)('protobuf payload - still parses tool-call markers from extracted strings', () => {
52
- const payload = new protobuf_js_1.ProtobufEncoder();
53
- payload.writeVarint(1, 150);
54
- payload.writeString(2, '[TOOL_CALLS]answer[ARGS]{"answer":"final answer"}');
55
- const result = (0, response_converter_js_1.convertResponse)(payload.toBuffer());
56
- (0, vitest_1.expect)(result).toEqual([
57
- {
58
- type: 'tool-call',
59
- toolCallId: 'toolcall_1',
60
- toolName: 'answer',
61
- input: { answer: 'final answer' },
62
- },
63
- ]);
64
- });
65
19
  (0, vitest_1.it)('protobuf payload - ignores metadata strings and keeps main text field', () => {
66
20
  const payload = new protobuf_js_1.ProtobufEncoder();
67
21
  payload.writeString(1, 'meta');
@@ -79,70 +33,52 @@ const response_converter_js_1 = require("./response-converter.js");
79
33
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
80
34
  (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: 'Hello world' }]);
81
35
  });
82
- (0, vitest_1.it)('parses OpenAI-style TOOL_CALLS with numeric IDs', () => {
83
- const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"file_path":"/home/test","search_pattern":"binance"}}}{}';
36
+ // Strict OpenAI array format tests
37
+ (0, vitest_1.it)('parses strict OpenAI array with single tool call', () => {
38
+ const input = '[{"type":"function","function":{"name":"search","parameters":{"q":"test"}}}]';
84
39
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
85
40
  (0, vitest_1.expect)(result).toEqual([
86
- {
87
- type: 'tool-call',
88
- toolCallId: 'toolcall_1',
89
- toolName: 'grep',
90
- input: { file_path: '/home/test', search_pattern: 'binance' },
91
- },
41
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'search', input: { q: 'test' } }
92
42
  ]);
93
43
  });
94
- (0, vitest_1.it)('parses multiple OpenAI-style tool calls', () => {
95
- const input = 'TOOL_CALLS{"type":"function","function":{"name":1,"parameters":{"file_path":"/home/test"}}}, {"type":"function","function":{"name":2,"parameters":{"pattern":"*.ts"}}}{}';
44
+ (0, vitest_1.it)('parses strict OpenAI array with multiple tool calls', () => {
45
+ const input = '[{"type":"function","function":{"name":"read","parameters":{"path":"/a"}}},{"type":"function","function":{"name":"grep","parameters":{"pattern":"foo"}}}]';
96
46
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
97
47
  (0, vitest_1.expect)(result).toEqual([
98
- {
99
- type: 'tool-call',
100
- toolCallId: 'toolcall_1',
101
- toolName: 'read',
102
- input: { file_path: '/home/test' },
103
- },
104
- {
105
- type: 'tool-call',
106
- toolCallId: 'toolcall_2',
107
- toolName: 'glob',
108
- input: { pattern: '*.ts' },
109
- },
48
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'read', input: { path: '/a' } },
49
+ { type: 'tool-call', toolCallId: 'toolcall_2', toolName: 'grep', input: { pattern: 'foo' } }
110
50
  ]);
111
51
  });
112
- (0, vitest_1.it)('maps unknown tool IDs to tool_N format', () => {
113
- const input = 'TOOL_CALLS{"type":"function","function":{"name":99,"parameters":{"arg":"value"}}}{}';
52
+ (0, vitest_1.it)('parses strict OpenAI array with empty parameters', () => {
53
+ const input = '[{"type":"function","function":{"name":"list","parameters":{}}}]';
114
54
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
115
55
  (0, vitest_1.expect)(result).toEqual([
116
- {
117
- type: 'tool-call',
118
- toolCallId: 'toolcall_1',
119
- toolName: 'tool_99',
120
- input: { arg: 'value' },
121
- },
56
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'list', input: {} }
122
57
  ]);
123
58
  });
124
- (0, vitest_1.it)('handles OpenAI-style with string tool names', () => {
125
- const input = 'TOOL_CALLS{"type":"function","function":{"name":"custom_tool","parameters":{"key":"value"}}}{}';
59
+ (0, vitest_1.it)('returns text for old marker format (no backward compat)', () => {
60
+ const input = '[TOOL_CALLS]search[ARGS]{"q":"test"}';
126
61
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
127
- (0, vitest_1.expect)(result).toEqual([
128
- {
129
- type: 'tool-call',
130
- toolCallId: 'toolcall_1',
131
- toolName: 'custom_tool',
132
- input: { key: 'value' },
133
- },
134
- ]);
62
+ (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '[TOOL_CALLS]search[ARGS]{"q":"test"}' }]);
135
63
  });
136
- (0, vitest_1.it)('handles OpenAI-style with string name "answer" - emits as tool-call', () => {
137
- const input = 'TOOL_CALLS{"type":"function","function":{"name":"answer","parameters":{"answer":"final answer"}}}{}';
64
+ (0, vitest_1.it)('returns text for old TOOL_CALLS format (no backward compat)', () => {
65
+ const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"q":"test"}}}{}';
138
66
  const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
139
- (0, vitest_1.expect)(result).toEqual([
140
- {
141
- type: 'tool-call',
142
- toolCallId: 'toolcall_1',
143
- toolName: 'answer',
144
- input: { answer: 'final answer' },
145
- },
146
- ]);
67
+ (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
68
+ });
69
+ (0, vitest_1.it)('returns text for non-array JSON', () => {
70
+ const input = '{"type":"function","function":{"name":"search","parameters":{}}}';
71
+ const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
72
+ (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
73
+ });
74
+ (0, vitest_1.it)('returns text for empty array', () => {
75
+ const input = '[]';
76
+ const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
77
+ (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '[]' }]);
78
+ });
79
+ (0, vitest_1.it)('returns text for malformed JSON array', () => {
80
+ const input = '[{"type":"function","function":{"name":"search","parameters":';
81
+ const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
82
+ (0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
147
83
  });
148
84
  });
@@ -20,6 +20,13 @@ const WS_APP_VER = process.env.WS_APP_VER ?? '1.48.2';
20
20
  const WS_LS_VER = process.env.WS_LS_VER ?? '1.9544.35';
21
21
  const SENTRY_PUBLIC_KEY = 'b813f73488da69eedec534dba1029111';
22
22
  const CONNECT_USER_AGENT = 'connect-go/1.18.1 (go1.25.5)';
23
+ const TOOL_FORMAT_INSTRUCTION = `When you need to call tools, you must format your response as a JSON array of tool calls following the OpenAI function calling format. Each tool call must be an object with "type" set to "function" and a "function" object containing "name" (string matching one of the provided tool names) and "parameters" (object with the tool's arguments). Output ONLY the JSON array, no surrounding text.
24
+
25
+ Example format:
26
+ [{"type": "function", "function": {"name": "tool_name", "parameters": {"arg1": "value1"}}}]
27
+
28
+ For multiple tool calls:
29
+ [{"type": "function", "function": {"name": "tool1", "parameters": {"arg1": "value1"}}}, {"type": "function", "function": {"name": "tool2", "parameters": {"arg2": "value2"}}}]`;
23
30
  class DevstralLanguageModel {
24
31
  specificationVersion = 'v2';
25
32
  provider = 'windsurf';
@@ -271,10 +278,13 @@ function isFunctionTool(tool) {
271
278
  function buildGenerateRequest(input) {
272
279
  const request = new protobuf_js_1.ProtobufEncoder();
273
280
  request.writeMessage(1, buildMetadata(input.apiKey, input.jwt));
274
- for (const message of input.messages) {
281
+ const functionTools = input.tools?.filter(isFunctionTool) ?? [];
282
+ const messages = functionTools.length > 0
283
+ ? [{ role: 5, content: TOOL_FORMAT_INSTRUCTION }, ...input.messages]
284
+ : input.messages;
285
+ for (const message of messages) {
275
286
  request.writeMessage(2, buildMessage(message));
276
287
  }
277
- const functionTools = input.tools?.filter(isFunctionTool) ?? [];
278
288
  if (functionTools.length > 0) {
279
289
  const toolsArray = functionTools.map((tool) => ({
280
290
  type: 'function',
@@ -163,7 +163,7 @@ async function collectStreamParts(stream) {
163
163
  return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
164
164
  }
165
165
  requestBodies.push(bufferFromBody(init?.body));
166
- const payload = Buffer.from('[TOOL_CALLS]searchRepo[ARGS]{"query":"jwt manager"}', 'utf8');
166
+ const payload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
167
167
  return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(payload)), { status: 200 });
168
168
  };
169
169
  const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
@@ -275,6 +275,83 @@ async function collectStreamParts(stream) {
275
275
  const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
276
276
  (0, vitest_1.expect)(extractToolsPayload(strings)).toBeUndefined();
277
277
  });
278
+ (0, vitest_1.it)('injects tool format instruction into request when tools are present', async () => {
279
+ const requestBodies = [];
280
+ const jwt = makeJwt(4_200_000_100, 'instruction');
281
+ const fakeFetch = async (input, init) => {
282
+ const url = String(input);
283
+ if (url.endsWith('/GetUserJwt')) {
284
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
285
+ }
286
+ requestBodies.push(bufferFromBody(init?.body));
287
+ return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
288
+ };
289
+ const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
290
+ await model.doGenerate({
291
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search for auth.' }] }],
292
+ tools: [
293
+ {
294
+ type: 'function',
295
+ name: 'searchRepo',
296
+ description: 'Search repository files',
297
+ inputSchema: {
298
+ type: 'object',
299
+ properties: { query: { type: 'string' } },
300
+ required: ['query'],
301
+ },
302
+ },
303
+ ],
304
+ });
305
+ const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
306
+ const combined = strings.join('\n');
307
+ (0, vitest_1.expect)(combined).toContain('When you need to call tools');
308
+ });
309
+ (0, vitest_1.it)('does not inject tool format instruction when no tools provided', async () => {
310
+ const requestBodies = [];
311
+ const jwt = makeJwt(4_200_000_101, 'no-instruction');
312
+ const fakeFetch = async (input, init) => {
313
+ const url = String(input);
314
+ if (url.endsWith('/GetUserJwt')) {
315
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
316
+ }
317
+ requestBodies.push(bufferFromBody(init?.body));
318
+ return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
319
+ };
320
+ const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
321
+ await model.doGenerate({
322
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Find auth logic.' }] }],
323
+ });
324
+ const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
325
+ const combined = strings.join('\n');
326
+ (0, vitest_1.expect)(combined).not.toContain('When you need to call tools');
327
+ });
328
+ (0, vitest_1.it)('does not inject tool format instruction when only provider-defined tools', async () => {
329
+ const requestBodies = [];
330
+ const jwt = makeJwt(4_200_000_102, 'provider-only');
331
+ const fakeFetch = async (input, init) => {
332
+ const url = String(input);
333
+ if (url.endsWith('/GetUserJwt')) {
334
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
335
+ }
336
+ requestBodies.push(bufferFromBody(init?.body));
337
+ return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
338
+ };
339
+ const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
340
+ await model.doGenerate({
341
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use provider tools.' }] }],
342
+ tools: [
343
+ {
344
+ type: 'provider-defined',
345
+ id: 'some-provider-tool',
346
+ name: 'providerTool',
347
+ args: {},
348
+ },
349
+ ],
350
+ });
351
+ const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
352
+ const combined = strings.join('\n');
353
+ (0, vitest_1.expect)(combined).not.toContain('When you need to call tools');
354
+ });
278
355
  });
279
356
  (0, vitest_1.describe)('DevstralLanguageModel doStream', () => {
280
357
  (0, vitest_1.it)('stream-text resolves before full body arrives and emits parts incrementally', async () => {
@@ -379,7 +456,7 @@ async function collectStreamParts(stream) {
379
456
  });
380
457
  (0, vitest_1.it)('stream-tool emits tool-input deltas before final tool-call and finish', async () => {
381
458
  const jwt = makeJwt(4_300_000_001, 'stream-tool');
382
- const toolPayload = Buffer.from('[TOOL_CALLS]searchRepo[ARGS]{"query":"jwt manager"}', 'utf8');
459
+ const toolPayload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
383
460
  const frames = Buffer.concat([(0, connect_frame_js_1.connectFrameEncode)(toolPayload)]);
384
461
  const fakeFetch = async (input) => {
385
462
  const url = String(input);
@@ -1,191 +1,37 @@
1
1
  import { gunzipSync } from 'node:zlib';
2
2
  import { extractStrings } from '../protocol/protobuf.js';
3
- const TOOL_CALL_PREFIX = '[TOOL_CALLS]';
4
- const ARGS_PREFIX = '[ARGS]';
5
3
  const STOP_TOKEN = '</s>';
6
- function parseOpenAIToolCalls(responseText) {
7
- if (!responseText.startsWith('TOOL_CALLS')) {
4
+ function parseStrictOpenAIToolCalls(responseText) {
5
+ const trimmed = responseText.trim();
6
+ if (!trimmed.startsWith('['))
8
7
  return null;
8
+ try {
9
+ const parsed = JSON.parse(trimmed);
10
+ if (!Array.isArray(parsed) || parsed.length === 0)
11
+ return null;
12
+ const toolCalls = [];
13
+ for (let i = 0; i < parsed.length; i++) {
14
+ const item = parsed[i];
15
+ if (item.type !== 'function' || !item.function?.name)
16
+ continue;
17
+ toolCalls.push({
18
+ type: 'tool-call',
19
+ toolCallId: `toolcall_${i + 1}`,
20
+ toolName: String(item.function.name),
21
+ input: item.function.parameters ?? {},
22
+ });
23
+ }
24
+ return toolCalls.length > 0 ? toolCalls : null;
9
25
  }
10
- const jsonPart = responseText.slice('TOOL_CALLS'.length);
11
- if (!jsonPart.startsWith('{')) {
12
- return null;
13
- }
14
- const toolCalls = [];
15
- let cursor = 0;
16
- while (cursor < jsonPart.length) {
17
- if (jsonPart[cursor] !== '{') {
18
- cursor++;
19
- continue;
20
- }
21
- const endResult = parseBalancedEnd(jsonPart, cursor);
22
- if (endResult == null) {
23
- break;
24
- }
25
- const jsonStr = jsonPart.slice(cursor, endResult);
26
- cursor = endResult;
27
- if (jsonStr === '{}') {
28
- continue;
29
- }
30
- try {
31
- const parsed = JSON.parse(jsonStr);
32
- if (parsed.type === 'function' && parsed.function) {
33
- toolCalls.push(parsed);
34
- }
35
- }
36
- catch {
37
- continue;
38
- }
39
- }
40
- if (toolCalls.length === 0) {
26
+ catch {
41
27
  return null;
42
28
  }
43
- return toolCalls.map((call, index) => {
44
- const toolId = call.function.name;
45
- const toolName = typeof toolId === 'number' ? mapToolIdToName(toolId) : String(toolId);
46
- const args = call.function.parameters ?? {};
47
- return {
48
- type: 'tool-call',
49
- toolCallId: `toolcall_${index + 1}`,
50
- toolName,
51
- input: args,
52
- };
53
- });
54
- }
55
- function mapToolIdToName(id) {
56
- switch (id) {
57
- case 1:
58
- return 'read';
59
- case 2:
60
- return 'glob';
61
- case 3:
62
- return 'grep';
63
- default:
64
- return `tool_${id}`;
65
- }
66
29
  }
67
30
  function pushText(parts, text) {
68
31
  if (text.length > 0) {
69
32
  parts.push({ type: 'text', text });
70
33
  }
71
34
  }
72
- function parseStringEnd(value, startIndex) {
73
- let index = startIndex + 1;
74
- let escaping = false;
75
- while (index < value.length) {
76
- const char = value[index];
77
- if (escaping) {
78
- escaping = false;
79
- }
80
- else if (char === '\\') {
81
- escaping = true;
82
- }
83
- else if (char === '"') {
84
- return index + 1;
85
- }
86
- index += 1;
87
- }
88
- return null;
89
- }
90
- function parseBalancedEnd(value, startIndex) {
91
- const stack = [value[startIndex] === '{' ? '}' : ']'];
92
- let index = startIndex + 1;
93
- let inString = false;
94
- let escaping = false;
95
- while (index < value.length) {
96
- const char = value[index];
97
- if (inString) {
98
- if (escaping) {
99
- escaping = false;
100
- }
101
- else if (char === '\\') {
102
- escaping = true;
103
- }
104
- else if (char === '"') {
105
- inString = false;
106
- }
107
- index += 1;
108
- continue;
109
- }
110
- if (char === '"') {
111
- inString = true;
112
- index += 1;
113
- continue;
114
- }
115
- if (char === '{') {
116
- stack.push('}');
117
- index += 1;
118
- continue;
119
- }
120
- if (char === '[') {
121
- stack.push(']');
122
- index += 1;
123
- continue;
124
- }
125
- if (char === '}' || char === ']') {
126
- const expected = stack[stack.length - 1];
127
- if (expected !== char) {
128
- return null;
129
- }
130
- stack.pop();
131
- index += 1;
132
- if (stack.length === 0) {
133
- return index;
134
- }
135
- continue;
136
- }
137
- index += 1;
138
- }
139
- return null;
140
- }
141
- function parsePrimitiveEnd(value, startIndex) {
142
- let index = startIndex;
143
- while (index < value.length) {
144
- const char = value[index];
145
- if (char === ' ' || char === '\n' || char === '\r' || char === '\t') {
146
- break;
147
- }
148
- index += 1;
149
- }
150
- return index;
151
- }
152
- function parseJsonValue(value, startIndex) {
153
- let jsonStart = startIndex;
154
- while (jsonStart < value.length) {
155
- const char = value[jsonStart];
156
- if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') {
157
- break;
158
- }
159
- jsonStart += 1;
160
- }
161
- if (jsonStart >= value.length) {
162
- return null;
163
- }
164
- const firstChar = value[jsonStart];
165
- let endIndex;
166
- if (firstChar === '{' || firstChar === '[') {
167
- endIndex = parseBalancedEnd(value, jsonStart);
168
- }
169
- else if (firstChar === '"') {
170
- endIndex = parseStringEnd(value, jsonStart);
171
- }
172
- else {
173
- endIndex = parsePrimitiveEnd(value, jsonStart);
174
- }
175
- if (endIndex == null || endIndex <= jsonStart) {
176
- return null;
177
- }
178
- const rawJson = value.slice(jsonStart, endIndex);
179
- try {
180
- return {
181
- parsed: JSON.parse(rawJson),
182
- endIndex,
183
- };
184
- }
185
- catch {
186
- return null;
187
- }
188
- }
189
35
  function hasControlChars(value) {
190
36
  for (const char of value) {
191
37
  const code = char.charCodeAt(0);
@@ -213,10 +59,6 @@ function isLikelyMetadata(value) {
213
59
  return /^[A-Za-z0-9._:-]{1,32}$/.test(value);
214
60
  }
215
61
  function pickBestExtractedText(values) {
216
- const markerValues = values.filter((value) => value.includes(TOOL_CALL_PREFIX) || value.includes(ARGS_PREFIX));
217
- if (markerValues.length > 0) {
218
- return markerValues.join('');
219
- }
220
62
  const nonMetadata = values.filter((value) => !isLikelyMetadata(value));
221
63
  const candidates = nonMetadata.length > 0 ? nonMetadata : values;
222
64
  return candidates.reduce((best, current) => (current.length > best.length ? current : best), candidates[0] ?? '');
@@ -236,43 +78,10 @@ function decodeResponseText(buffer) {
236
78
  export function convertResponse(buffer) {
237
79
  let responseText = decodeResponseText(buffer);
238
80
  responseText = responseText.replace(STOP_TOKEN, '');
239
- const openaiToolCalls = parseOpenAIToolCalls(responseText);
240
- if (openaiToolCalls) {
241
- return openaiToolCalls;
242
- }
81
+ const strictToolCalls = parseStrictOpenAIToolCalls(responseText);
82
+ if (strictToolCalls)
83
+ return strictToolCalls;
243
84
  const parts = [];
244
- let cursor = 0;
245
- let toolCallCount = 0;
246
- while (cursor < responseText.length) {
247
- const markerStart = responseText.indexOf(TOOL_CALL_PREFIX, cursor);
248
- if (markerStart === -1) {
249
- pushText(parts, responseText.slice(cursor));
250
- break;
251
- }
252
- pushText(parts, responseText.slice(cursor, markerStart));
253
- const toolNameStart = markerStart + TOOL_CALL_PREFIX.length;
254
- const argsStart = responseText.indexOf(ARGS_PREFIX, toolNameStart);
255
- if (argsStart === -1) {
256
- pushText(parts, responseText.slice(markerStart));
257
- break;
258
- }
259
- const toolName = responseText.slice(toolNameStart, argsStart);
260
- const parsedArgs = parseJsonValue(responseText, argsStart + ARGS_PREFIX.length);
261
- if (parsedArgs == null) {
262
- const nextMarker = responseText.indexOf(TOOL_CALL_PREFIX, markerStart + TOOL_CALL_PREFIX.length);
263
- const malformedEnd = nextMarker === -1 ? responseText.length : nextMarker;
264
- pushText(parts, responseText.slice(markerStart, malformedEnd));
265
- cursor = malformedEnd;
266
- continue;
267
- }
268
- toolCallCount += 1;
269
- parts.push({
270
- type: 'tool-call',
271
- toolCallId: `toolcall_${toolCallCount}`,
272
- toolName,
273
- input: parsedArgs.parsed,
274
- });
275
- cursor = parsedArgs.endIndex;
276
- }
85
+ pushText(parts, responseText);
277
86
  return parts;
278
87
  }
@@ -7,38 +7,6 @@ describe('convertResponse', () => {
7
7
  const result = convertResponse(Buffer.from('plain response text', 'utf8'));
8
8
  expect(result).toEqual([{ type: 'text', text: 'plain response text' }]);
9
9
  });
10
- it('tool-call - parses tool markers and preserves surrounding text', () => {
11
- const input = 'Before [TOOL_CALLS]searchDocs[ARGS]{"query":"prompt converter"} between [TOOL_CALLS]answer[ARGS]{"answer":"final answer"} after';
12
- const result = convertResponse(Buffer.from(input, 'utf8'));
13
- expect(result).toEqual([
14
- { type: 'text', text: 'Before ' },
15
- {
16
- type: 'tool-call',
17
- toolCallId: 'toolcall_1',
18
- toolName: 'searchDocs',
19
- input: { query: 'prompt converter' },
20
- },
21
- { type: 'text', text: ' between ' },
22
- {
23
- type: 'tool-call',
24
- toolCallId: 'toolcall_2',
25
- toolName: 'answer',
26
- input: { answer: 'final answer' },
27
- },
28
- { type: 'text', text: ' after' },
29
- ]);
30
- });
31
- it('malformed - invalid marker json remains text and does not throw', () => {
32
- const input = 'prefix [TOOL_CALLS]searchDocs[ARGS]{"query": nope} suffix';
33
- expect(() => convertResponse(Buffer.from(input, 'utf8'))).not.toThrow();
34
- const result = convertResponse(Buffer.from(input, 'utf8'));
35
- const combinedText = result
36
- .filter((part) => part.type === 'text')
37
- .map((part) => part.text)
38
- .join('');
39
- expect(result.every((part) => part.type === 'text')).toBe(true);
40
- expect(combinedText).toBe(input);
41
- });
42
10
  it('protobuf payload - extracts clean utf8 text without binary mojibake prefix', () => {
43
11
  const payload = new ProtobufEncoder();
44
12
  payload.writeVarint(1, 150);
@@ -46,20 +14,6 @@ describe('convertResponse', () => {
46
14
  const result = convertResponse(payload.toBuffer());
47
15
  expect(result).toEqual([{ type: 'text', text: '你好,TypeScript' }]);
48
16
  });
49
- it('protobuf payload - still parses tool-call markers from extracted strings', () => {
50
- const payload = new ProtobufEncoder();
51
- payload.writeVarint(1, 150);
52
- payload.writeString(2, '[TOOL_CALLS]answer[ARGS]{"answer":"final answer"}');
53
- const result = convertResponse(payload.toBuffer());
54
- expect(result).toEqual([
55
- {
56
- type: 'tool-call',
57
- toolCallId: 'toolcall_1',
58
- toolName: 'answer',
59
- input: { answer: 'final answer' },
60
- },
61
- ]);
62
- });
63
17
  it('protobuf payload - ignores metadata strings and keeps main text field', () => {
64
18
  const payload = new ProtobufEncoder();
65
19
  payload.writeString(1, 'meta');
@@ -77,70 +31,52 @@ describe('convertResponse', () => {
77
31
  const result = convertResponse(Buffer.from(input, 'utf8'));
78
32
  expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
79
33
  });
80
- it('parses OpenAI-style TOOL_CALLS with numeric IDs', () => {
81
- const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"file_path":"/home/test","search_pattern":"binance"}}}{}';
34
+ // Strict OpenAI array format tests
35
+ it('parses strict OpenAI array with single tool call', () => {
36
+ const input = '[{"type":"function","function":{"name":"search","parameters":{"q":"test"}}}]';
82
37
  const result = convertResponse(Buffer.from(input, 'utf8'));
83
38
  expect(result).toEqual([
84
- {
85
- type: 'tool-call',
86
- toolCallId: 'toolcall_1',
87
- toolName: 'grep',
88
- input: { file_path: '/home/test', search_pattern: 'binance' },
89
- },
39
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'search', input: { q: 'test' } }
90
40
  ]);
91
41
  });
92
- it('parses multiple OpenAI-style tool calls', () => {
93
- const input = 'TOOL_CALLS{"type":"function","function":{"name":1,"parameters":{"file_path":"/home/test"}}}, {"type":"function","function":{"name":2,"parameters":{"pattern":"*.ts"}}}{}';
42
+ it('parses strict OpenAI array with multiple tool calls', () => {
43
+ const input = '[{"type":"function","function":{"name":"read","parameters":{"path":"/a"}}},{"type":"function","function":{"name":"grep","parameters":{"pattern":"foo"}}}]';
94
44
  const result = convertResponse(Buffer.from(input, 'utf8'));
95
45
  expect(result).toEqual([
96
- {
97
- type: 'tool-call',
98
- toolCallId: 'toolcall_1',
99
- toolName: 'read',
100
- input: { file_path: '/home/test' },
101
- },
102
- {
103
- type: 'tool-call',
104
- toolCallId: 'toolcall_2',
105
- toolName: 'glob',
106
- input: { pattern: '*.ts' },
107
- },
46
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'read', input: { path: '/a' } },
47
+ { type: 'tool-call', toolCallId: 'toolcall_2', toolName: 'grep', input: { pattern: 'foo' } }
108
48
  ]);
109
49
  });
110
- it('maps unknown tool IDs to tool_N format', () => {
111
- const input = 'TOOL_CALLS{"type":"function","function":{"name":99,"parameters":{"arg":"value"}}}{}';
50
+ it('parses strict OpenAI array with empty parameters', () => {
51
+ const input = '[{"type":"function","function":{"name":"list","parameters":{}}}]';
112
52
  const result = convertResponse(Buffer.from(input, 'utf8'));
113
53
  expect(result).toEqual([
114
- {
115
- type: 'tool-call',
116
- toolCallId: 'toolcall_1',
117
- toolName: 'tool_99',
118
- input: { arg: 'value' },
119
- },
54
+ { type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'list', input: {} }
120
55
  ]);
121
56
  });
122
- it('handles OpenAI-style with string tool names', () => {
123
- const input = 'TOOL_CALLS{"type":"function","function":{"name":"custom_tool","parameters":{"key":"value"}}}{}';
57
+ it('returns text for old marker format (no backward compat)', () => {
58
+ const input = '[TOOL_CALLS]search[ARGS]{"q":"test"}';
124
59
  const result = convertResponse(Buffer.from(input, 'utf8'));
125
- expect(result).toEqual([
126
- {
127
- type: 'tool-call',
128
- toolCallId: 'toolcall_1',
129
- toolName: 'custom_tool',
130
- input: { key: 'value' },
131
- },
132
- ]);
60
+ expect(result).toEqual([{ type: 'text', text: '[TOOL_CALLS]search[ARGS]{"q":"test"}' }]);
133
61
  });
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"}}}{}';
62
+ it('returns text for old TOOL_CALLS format (no backward compat)', () => {
63
+ const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"q":"test"}}}{}';
136
64
  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
- ]);
65
+ expect(result).toEqual([{ type: 'text', text: input }]);
66
+ });
67
+ it('returns text for non-array JSON', () => {
68
+ const input = '{"type":"function","function":{"name":"search","parameters":{}}}';
69
+ const result = convertResponse(Buffer.from(input, 'utf8'));
70
+ expect(result).toEqual([{ type: 'text', text: input }]);
71
+ });
72
+ it('returns text for empty array', () => {
73
+ const input = '[]';
74
+ const result = convertResponse(Buffer.from(input, 'utf8'));
75
+ expect(result).toEqual([{ type: 'text', text: '[]' }]);
76
+ });
77
+ it('returns text for malformed JSON array', () => {
78
+ const input = '[{"type":"function","function":{"name":"search","parameters":';
79
+ const result = convertResponse(Buffer.from(input, 'utf8'));
80
+ expect(result).toEqual([{ type: 'text', text: input }]);
145
81
  });
146
82
  });
@@ -17,6 +17,13 @@ const WS_APP_VER = process.env.WS_APP_VER ?? '1.48.2';
17
17
  const WS_LS_VER = process.env.WS_LS_VER ?? '1.9544.35';
18
18
  const SENTRY_PUBLIC_KEY = 'b813f73488da69eedec534dba1029111';
19
19
  const CONNECT_USER_AGENT = 'connect-go/1.18.1 (go1.25.5)';
20
+ const TOOL_FORMAT_INSTRUCTION = `When you need to call tools, you must format your response as a JSON array of tool calls following the OpenAI function calling format. Each tool call must be an object with "type" set to "function" and a "function" object containing "name" (string matching one of the provided tool names) and "parameters" (object with the tool's arguments). Output ONLY the JSON array, no surrounding text.
21
+
22
+ Example format:
23
+ [{"type": "function", "function": {"name": "tool_name", "parameters": {"arg1": "value1"}}}]
24
+
25
+ For multiple tool calls:
26
+ [{"type": "function", "function": {"name": "tool1", "parameters": {"arg1": "value1"}}}, {"type": "function", "function": {"name": "tool2", "parameters": {"arg2": "value2"}}}]`;
20
27
  export class DevstralLanguageModel {
21
28
  specificationVersion = 'v2';
22
29
  provider = 'windsurf';
@@ -267,10 +274,13 @@ function isFunctionTool(tool) {
267
274
  function buildGenerateRequest(input) {
268
275
  const request = new ProtobufEncoder();
269
276
  request.writeMessage(1, buildMetadata(input.apiKey, input.jwt));
270
- for (const message of input.messages) {
277
+ const functionTools = input.tools?.filter(isFunctionTool) ?? [];
278
+ const messages = functionTools.length > 0
279
+ ? [{ role: 5, content: TOOL_FORMAT_INSTRUCTION }, ...input.messages]
280
+ : input.messages;
281
+ for (const message of messages) {
271
282
  request.writeMessage(2, buildMessage(message));
272
283
  }
273
- const functionTools = input.tools?.filter(isFunctionTool) ?? [];
274
284
  if (functionTools.length > 0) {
275
285
  const toolsArray = functionTools.map((tool) => ({
276
286
  type: 'function',
@@ -161,7 +161,7 @@ describe('DevstralLanguageModel doGenerate', () => {
161
161
  return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
162
162
  }
163
163
  requestBodies.push(bufferFromBody(init?.body));
164
- const payload = Buffer.from('[TOOL_CALLS]searchRepo[ARGS]{"query":"jwt manager"}', 'utf8');
164
+ const payload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
165
165
  return new Response(Uint8Array.from(connectFrameEncode(payload)), { status: 200 });
166
166
  };
167
167
  const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
@@ -273,6 +273,83 @@ describe('DevstralLanguageModel doGenerate', () => {
273
273
  const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
274
274
  expect(extractToolsPayload(strings)).toBeUndefined();
275
275
  });
276
+ it('injects tool format instruction into request when tools are present', async () => {
277
+ const requestBodies = [];
278
+ const jwt = makeJwt(4_200_000_100, 'instruction');
279
+ const fakeFetch = async (input, init) => {
280
+ const url = String(input);
281
+ if (url.endsWith('/GetUserJwt')) {
282
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
283
+ }
284
+ requestBodies.push(bufferFromBody(init?.body));
285
+ return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
286
+ };
287
+ const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
288
+ await model.doGenerate({
289
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search for auth.' }] }],
290
+ tools: [
291
+ {
292
+ type: 'function',
293
+ name: 'searchRepo',
294
+ description: 'Search repository files',
295
+ inputSchema: {
296
+ type: 'object',
297
+ properties: { query: { type: 'string' } },
298
+ required: ['query'],
299
+ },
300
+ },
301
+ ],
302
+ });
303
+ const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
304
+ const combined = strings.join('\n');
305
+ expect(combined).toContain('When you need to call tools');
306
+ });
307
+ it('does not inject tool format instruction when no tools provided', async () => {
308
+ const requestBodies = [];
309
+ const jwt = makeJwt(4_200_000_101, 'no-instruction');
310
+ const fakeFetch = async (input, init) => {
311
+ const url = String(input);
312
+ if (url.endsWith('/GetUserJwt')) {
313
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
314
+ }
315
+ requestBodies.push(bufferFromBody(init?.body));
316
+ return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
317
+ };
318
+ const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
319
+ await model.doGenerate({
320
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Find auth logic.' }] }],
321
+ });
322
+ const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
323
+ const combined = strings.join('\n');
324
+ expect(combined).not.toContain('When you need to call tools');
325
+ });
326
+ it('does not inject tool format instruction when only provider-defined tools', async () => {
327
+ const requestBodies = [];
328
+ const jwt = makeJwt(4_200_000_102, 'provider-only');
329
+ const fakeFetch = async (input, init) => {
330
+ const url = String(input);
331
+ if (url.endsWith('/GetUserJwt')) {
332
+ return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
333
+ }
334
+ requestBodies.push(bufferFromBody(init?.body));
335
+ return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
336
+ };
337
+ const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
338
+ await model.doGenerate({
339
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use provider tools.' }] }],
340
+ tools: [
341
+ {
342
+ type: 'provider-defined',
343
+ id: 'some-provider-tool',
344
+ name: 'providerTool',
345
+ args: {},
346
+ },
347
+ ],
348
+ });
349
+ const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
350
+ const combined = strings.join('\n');
351
+ expect(combined).not.toContain('When you need to call tools');
352
+ });
276
353
  });
277
354
  describe('DevstralLanguageModel doStream', () => {
278
355
  it('stream-text resolves before full body arrives and emits parts incrementally', async () => {
@@ -377,7 +454,7 @@ describe('DevstralLanguageModel doStream', () => {
377
454
  });
378
455
  it('stream-tool emits tool-input deltas before final tool-call and finish', async () => {
379
456
  const jwt = makeJwt(4_300_000_001, 'stream-tool');
380
- const toolPayload = Buffer.from('[TOOL_CALLS]searchRepo[ARGS]{"query":"jwt manager"}', 'utf8');
457
+ const toolPayload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
381
458
  const frames = Buffer.concat([connectFrameEncode(toolPayload)]);
382
459
  const fakeFetch = async (input) => {
383
460
  const url = String(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bxb1337/windsurf-fast-context",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "AI SDK V3 provider for Windsurf's Devstral code search API",
5
5
  "type": "module",
6
6
  "scripts": {