@pedrofariasx/qwenproxy 1.1.0

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.
Files changed (59) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +292 -0
  3. package/bin/qwenproxy.mjs +11 -0
  4. package/package.json +56 -0
  5. package/src/api/models.ts +183 -0
  6. package/src/api/server.ts +126 -0
  7. package/src/cache/memory-cache.ts +186 -0
  8. package/src/core/account-manager.ts +132 -0
  9. package/src/core/accounts.ts +78 -0
  10. package/src/core/config.ts +91 -0
  11. package/src/core/database.ts +92 -0
  12. package/src/core/logger.ts +96 -0
  13. package/src/core/metrics.ts +169 -0
  14. package/src/core/model-registry.ts +30 -0
  15. package/src/core/stream-registry.ts +40 -0
  16. package/src/core/watchdog.ts +130 -0
  17. package/src/index.ts +7 -0
  18. package/src/linter/extraction-engine.ts +165 -0
  19. package/src/linter/index.ts +258 -0
  20. package/src/linter/repair-normalize.ts +245 -0
  21. package/src/linter/safety-gate.ts +219 -0
  22. package/src/linter/streaming-state-machine.ts +252 -0
  23. package/src/linter/structural-parser.ts +352 -0
  24. package/src/linter/types.ts +74 -0
  25. package/src/login.ts +228 -0
  26. package/src/routes/chat.ts +801 -0
  27. package/src/routes/upload.ts +700 -0
  28. package/src/services/playwright.ts +778 -0
  29. package/src/services/qwen.ts +500 -0
  30. package/src/tests/advanced.test.ts +227 -0
  31. package/src/tests/agenticStress.test.ts +360 -0
  32. package/src/tests/concurrency.test.ts +103 -0
  33. package/src/tests/concurrentChat.test.ts +71 -0
  34. package/src/tests/delta.test.ts +63 -0
  35. package/src/tests/index.test.ts +356 -0
  36. package/src/tests/jsonFix.test.ts +98 -0
  37. package/src/tests/linter.test.ts +151 -0
  38. package/src/tests/parallel.test.ts +42 -0
  39. package/src/tests/parser.test.ts +89 -0
  40. package/src/tests/rotation.test.ts +45 -0
  41. package/src/tests/streamingOptimizations.test.ts +328 -0
  42. package/src/tests/structureVerification.test.ts +176 -0
  43. package/src/tools/ast.ts +15 -0
  44. package/src/tools/coercion.ts +67 -0
  45. package/src/tools/confidence.ts +48 -0
  46. package/src/tools/detector.ts +40 -0
  47. package/src/tools/executor.ts +236 -0
  48. package/src/tools/parser.ts +446 -0
  49. package/src/tools/pipeline.ts +122 -0
  50. package/src/tools/registry-runtime.ts +34 -0
  51. package/src/tools/registry.ts +142 -0
  52. package/src/tools/repair.ts +42 -0
  53. package/src/tools/schema.ts +285 -0
  54. package/src/tools/types.ts +104 -0
  55. package/src/tools/validator.ts +33 -0
  56. package/src/utils/context-truncation.ts +61 -0
  57. package/src/utils/json.ts +114 -0
  58. package/src/utils/qwen-stream-parser.ts +286 -0
  59. package/src/utils/types.ts +101 -0
@@ -0,0 +1,227 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ process.env.TEST_MOCK_PLAYWRIGHT = 'true';
5
+
6
+ delete process.env.API_KEY;
7
+
8
+ import { app } from '../api/server.js';
9
+
10
+ function setupFetchMock(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
11
+ const originalFetch = globalThis.fetch;
12
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
13
+ const urlStr = typeof input === 'string' ? input : ('url' in input ? input.url : String(input));
14
+ if (urlStr.includes('chat.qwen.ai')) {
15
+ if (urlStr.includes('/api/models')) {
16
+ return new Response(JSON.stringify({ data: [{ id: 'qwen3.6-plus', owned_by: 'qwen' }] }), { status: 200 });
17
+ }
18
+ return handler(urlStr, init);
19
+ }
20
+ return originalFetch(input);
21
+ };
22
+ return () => { globalThis.fetch = originalFetch; };
23
+ }
24
+
25
+ test('multiturn-thinking-tools: maintains reasoning_content history', async () => {
26
+ let capturedBody = '';
27
+
28
+ const restore = setupFetchMock((url, init) => {
29
+ capturedBody = init?.body as string || '';
30
+ const stream = new ReadableStream({
31
+ start(c) {
32
+ c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
33
+ c.close();
34
+ }
35
+ });
36
+ return new Response(stream, { status: 200 });
37
+ });
38
+
39
+ try {
40
+ const req = new Request('http://localhost/v1/chat/completions', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({
44
+ model: 'qwen3.6-plus',
45
+ messages: [
46
+ { role: 'user', content: 'hello' },
47
+ { role: 'assistant', content: 'doing something', reasoning_content: 'thinking about hello', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }] },
48
+ { role: 'tool', name: 'test', content: 'success' }
49
+ ]
50
+ })
51
+ });
52
+
53
+ const res = await app.fetch(req);
54
+ assert.strictEqual(res.status, 200);
55
+
56
+ // The proxy transforms messages into a Qwen-compatible prompt.
57
+ // Verify the prompt (in the request body) contains context from all messages.
58
+ assert.ok(capturedBody.includes('hello') || capturedBody.includes('User: hello'), 'Must include user message');
59
+ assert.ok(capturedBody.includes('thinking about hello'), 'Must include reasoning content');
60
+ assert.ok(capturedBody.includes('tool_call') || capturedBody.includes('"name": "test"'), 'Must include tool call info');
61
+ assert.ok(capturedBody.includes('Tool Response (test): success') || capturedBody.includes('success'), 'Must include tool response');
62
+ } finally {
63
+ restore();
64
+ }
65
+ });
66
+
67
+ test('streaming-whitespace: preserves exact whitespace', async () => {
68
+ const restore = setupFetchMock((url) => {
69
+ const stream = new ReadableStream({
70
+ start(c) {
71
+ c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": " ", "phase": "answer"}}]}\n\n'));
72
+ c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": " hello ", "phase": "answer"}}]}\n\n'));
73
+ c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": "\\n\\n ", "phase": "answer"}}]}\n\n'));
74
+ c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
75
+ c.close();
76
+ }
77
+ });
78
+ return new Response(stream, { status: 200 });
79
+ });
80
+
81
+ try {
82
+ const req = new Request('http://localhost/v1/chat/completions', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({ model: 'qwen3.6-plus', messages: [{role: 'user', content: 'test'}], stream: true })
86
+ });
87
+
88
+ const res = await app.fetch(req);
89
+ const reader = res.body?.getReader();
90
+ const decoder = new TextDecoder();
91
+ let full = '';
92
+ while (true) {
93
+ const { done, value } = await reader!.read();
94
+ if (done) break;
95
+ const chunk = decoder.decode(value);
96
+ for (const line of chunk.split('\n')) {
97
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
98
+ try {
99
+ const data = JSON.parse(line.slice(6));
100
+ if (data.choices?.[0]?.delta?.content) {
101
+ full += data.choices[0].delta.content;
102
+ }
103
+ } catch(e) {}
104
+ }
105
+ }
106
+ }
107
+
108
+ // We expect exactly: " hello \n\n "
109
+ assert.strictEqual(full, " hello \n\n ");
110
+ } finally {
111
+ restore();
112
+ }
113
+ });
114
+
115
+ test('caching-streaming and cache-control: returns prompt_tokens_details', async () => {
116
+ const restore = setupFetchMock((url) => {
117
+ const stream = new ReadableStream({
118
+ start(c) {
119
+ c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": "done", "phase": "answer"}}], "usage": {"output_tokens": 10}}\n\n'));
120
+ c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
121
+ c.close();
122
+ }
123
+ });
124
+ return new Response(stream, { status: 200 });
125
+ });
126
+
127
+ try {
128
+ const req = new Request('http://localhost/v1/chat/completions', {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ model: 'qwen3.6-plus', messages: [{role: 'user', content: 'test'}], stream: true })
132
+ });
133
+
134
+ const res = await app.fetch(req);
135
+ const reader = res.body?.getReader();
136
+ const decoder = new TextDecoder();
137
+ let usageBlock = null;
138
+ while (true) {
139
+ const { done, value } = await reader!.read();
140
+ if (done) break;
141
+ const chunk = decoder.decode(value);
142
+ for (const line of chunk.split('\n')) {
143
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
144
+ try {
145
+ const data = JSON.parse(line.slice(6));
146
+ if (data.usage) {
147
+ usageBlock = data.usage;
148
+ }
149
+ } catch(e) {}
150
+ }
151
+ }
152
+ }
153
+
154
+ assert.ok(usageBlock);
155
+ assert.strictEqual(usageBlock.completion_tokens, 10);
156
+ assert.ok(usageBlock.prompt_tokens > 0);
157
+ assert.strictEqual(usageBlock.prompt_tokens_details.cached_tokens, 0); // Tests caching-streaming shape!
158
+ } finally {
159
+ restore();
160
+ }
161
+ });
162
+
163
+ test('session-parent-tracking: appends messages using response message_id as parent', async () => {
164
+ let capturedPayloads: any[] = [];
165
+
166
+ const restore = setupFetchMock((url, init) => {
167
+ const bodyObj = JSON.parse(init?.body as string || '{}');
168
+ capturedPayloads.push(bodyObj);
169
+
170
+ // Simulate Qwen returning a response_id
171
+ const mockMessageId = capturedPayloads.length === 1 ? 'qwen-1001' : 'qwen-1002';
172
+
173
+ const stream = new ReadableStream({
174
+ start(c) {
175
+ c.enqueue(new TextEncoder().encode(`data: {"response.created":{"response_id":"${mockMessageId}"}}\n\n`));
176
+ c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
177
+ c.close();
178
+ }
179
+ });
180
+ return new Response(stream, { status: 200 });
181
+ });
182
+
183
+ try {
184
+ process.env.TEST_SESSION_ID = 'test-session-parent-tracking';
185
+ // Turn 1
186
+ const req1 = new Request('http://localhost/v1/chat/completions', {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({
190
+ model: 'qwen3.6-plus',
191
+ messages: [{ role: 'user', content: 'Turn 1' }]
192
+ })
193
+ });
194
+
195
+ const res1 = await app.fetch(req1);
196
+ assert.strictEqual(res1.status, 200);
197
+ // Consume the stream to ensure the message_id is processed
198
+ await res1.text();
199
+
200
+ // Turn 2
201
+ const req2 = new Request('http://localhost/v1/chat/completions', {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({
205
+ model: 'qwen3.6-plus',
206
+ messages: [
207
+ { role: 'user', content: 'Turn 1' },
208
+ { role: 'assistant', content: 'Response 1' },
209
+ { role: 'user', content: 'Turn 2' }
210
+ ]
211
+ })
212
+ });
213
+
214
+ const res2 = await app.fetch(req2);
215
+ assert.strictEqual(res2.status, 200);
216
+ await res2.text();
217
+
218
+ assert.strictEqual(capturedPayloads.length, 2);
219
+ // In Turn 1, parent_id should be null (mock-session is fresh)
220
+ assert.strictEqual(capturedPayloads[0].parent_id, null);
221
+ // In Turn 2, parent_id should be qwen-1001 (the ID returned in Turn 1)
222
+ assert.strictEqual(capturedPayloads[1].parent_id, 'qwen-1001', 'Turn 2 should use response_id from Turn 1 as parent');
223
+ assert.strictEqual(capturedPayloads[1].messages[0].content, 'User: Turn 1\n\nAssistant: Response 1\n\nUser: Turn 2\n\n', 'Should send the full OpenAI message history');
224
+ } finally {
225
+ restore();
226
+ }
227
+ });
@@ -0,0 +1,360 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import net from 'node:net';
6
+ import { serve } from '@hono/node-server';
7
+ import { app } from '../api/server.js';
8
+ import { initPlaywright, closePlaywright } from '../services/playwright.ts';
9
+
10
+ const SANDBOX_DIR = '/tmp/kilo/sandbox';
11
+
12
+ function isPortAvailable(port: number): Promise<boolean> {
13
+ return new Promise((resolve) => {
14
+ const server = net.createServer();
15
+ server.once('error', () => {
16
+ resolve(false);
17
+ });
18
+ server.once('listening', () => {
19
+ server.close(() => {
20
+ resolve(true);
21
+ });
22
+ });
23
+ server.listen(port);
24
+ });
25
+ }
26
+
27
+ async function getFreePort(startPort: number): Promise<number> {
28
+ let port = startPort;
29
+ while (true) {
30
+ const available = await isPortAvailable(port);
31
+ if (available) return port;
32
+ port++;
33
+ }
34
+ }
35
+
36
+ // Clean and recreate physical sandbox directory
37
+ if (fs.existsSync(SANDBOX_DIR)) {
38
+ fs.rmSync(SANDBOX_DIR, { recursive: true, force: true });
39
+ }
40
+ fs.mkdirSync(SANDBOX_DIR, { recursive: true });
41
+
42
+ function normalizePath(p: string): string {
43
+ return p.replace(/\\/g, '/').toLowerCase();
44
+ }
45
+
46
+ // Local real file system tool handlers
47
+ const localTools = {
48
+ list_files: () => {
49
+ const files = fs.readdirSync(SANDBOX_DIR);
50
+ return JSON.stringify(files);
51
+ },
52
+ create_file: (args: { path: string; content: string }) => {
53
+ const filePath = path.join(SANDBOX_DIR, args.path);
54
+ const basePath = normalizePath(SANDBOX_DIR) + '/';
55
+ if (!normalizePath(filePath).startsWith(basePath)) {
56
+ return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
57
+ }
58
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
+ fs.writeFileSync(filePath, args.content, 'utf8');
60
+ return JSON.stringify({ status: 'success', path: args.path });
61
+ },
62
+ read_file: (args: { path: string }) => {
63
+ const filePath = path.join(SANDBOX_DIR, args.path);
64
+ const basePath = normalizePath(SANDBOX_DIR) + '/';
65
+ if (!normalizePath(filePath).startsWith(basePath)) {
66
+ return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
67
+ }
68
+ if (!fs.existsSync(filePath)) {
69
+ return JSON.stringify({ error: `File ${args.path} not found` });
70
+ }
71
+ const content = fs.readFileSync(filePath, 'utf8');
72
+ return JSON.stringify({ content });
73
+ },
74
+ edit_file: (args: { path: string; oldText: string; newText: string }) => {
75
+ const filePath = path.join(SANDBOX_DIR, args.path);
76
+ const basePath = normalizePath(SANDBOX_DIR) + '/';
77
+ if (!normalizePath(filePath).startsWith(basePath)) {
78
+ return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
79
+ }
80
+ if (!fs.existsSync(filePath)) {
81
+ return JSON.stringify({ error: `File ${args.path} not found` });
82
+ }
83
+ const content = fs.readFileSync(filePath, 'utf8');
84
+ if (!content.includes(args.oldText)) {
85
+ return JSON.stringify({ error: `Old text not found in ${args.path}` });
86
+ }
87
+ fs.writeFileSync(filePath, content.replace(args.oldText, args.newText), 'utf8');
88
+ return JSON.stringify({ status: 'success', path: args.path });
89
+ },
90
+ delete_file: (args: { path: string }) => {
91
+ const filePath = path.join(SANDBOX_DIR, args.path);
92
+ const basePath = normalizePath(SANDBOX_DIR) + '/';
93
+ if (!normalizePath(filePath).startsWith(basePath)) {
94
+ return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
95
+ }
96
+ if (!fs.existsSync(filePath)) {
97
+ return JSON.stringify({ error: `File ${args.path} not found` });
98
+ }
99
+ fs.unlinkSync(filePath);
100
+ return JSON.stringify({ status: 'success', path: args.path });
101
+ }
102
+ };
103
+
104
+ // Declares standard tool definitions in OpenAI format
105
+ const toolDefinitions = [
106
+ {
107
+ type: 'function',
108
+ function: {
109
+ name: 'list_files',
110
+ description: 'List all files in the sandbox',
111
+ parameters: { type: 'object', properties: {} }
112
+ }
113
+ },
114
+ {
115
+ type: 'function',
116
+ function: {
117
+ name: 'create_file',
118
+ description: 'Create a new file with content',
119
+ parameters: {
120
+ type: 'object',
121
+ properties: {
122
+ path: { type: 'string' },
123
+ content: { type: 'string' }
124
+ },
125
+ required: ['path', 'content']
126
+ }
127
+ }
128
+ },
129
+ {
130
+ type: 'function',
131
+ function: {
132
+ name: 'read_file',
133
+ description: 'Read the content of a file',
134
+ parameters: {
135
+ type: 'object',
136
+ properties: {
137
+ path: { type: 'string' }
138
+ },
139
+ required: ['path']
140
+ }
141
+ }
142
+ },
143
+ {
144
+ type: 'function',
145
+ function: {
146
+ name: 'edit_file',
147
+ description: 'Edit a file replacing oldText with newText',
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ path: { type: 'string' },
152
+ oldText: { type: 'string' },
153
+ newText: { type: 'string' }
154
+ },
155
+ required: ['path', 'oldText', 'newText']
156
+ }
157
+ }
158
+ },
159
+ {
160
+ type: 'function',
161
+ function: {
162
+ name: 'delete_file',
163
+ description: 'Delete a file',
164
+ parameters: {
165
+ type: 'object',
166
+ properties: {
167
+ path: { type: 'string' }
168
+ },
169
+ required: ['path']
170
+ }
171
+ }
172
+ }
173
+ ];
174
+
175
+ test('Agentic Stress Test: >30 messages multi-turn using the REAL live API', { skip: process.env.CI ? 'Requires real accounts - skipped in CI' : false }, async () => {
176
+ const DEFAULT_PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
177
+ const port = await getFreePort(DEFAULT_PORT);
178
+
179
+ const server = serve({
180
+ fetch: app.fetch,
181
+ port: port
182
+ });
183
+ console.log(`[RealTest] Local Hono server started on port ${port}`);
184
+
185
+ // Initialize Playwright with headless mode to fetch real active session headers
186
+ console.log('[RealTest] Initializing Playwright browser context...');
187
+ await initPlaywright(true);
188
+
189
+ // Dynamic conversation prompt sequence (explicitly instructing tool calls)
190
+ const conversationScenario = [
191
+ "Please call the 'list_files' tool to check if the sandbox is currently empty.",
192
+ "Great. Create a file named 'a.txt' with content 'Hello A' and a file named 'b.txt' with content 'Hello B' by calling the 'create_file' tool for both.",
193
+ "Please call the 'read_file' tool for 'b.txt' to verify its content.",
194
+ "Now, call the 'edit_file' tool to replace 'Hello A' with 'Hello Awesome World' in 'a.txt'.",
195
+ "Please call the 'read_file' tool for 'a.txt' to check if the change was applied successfully.",
196
+ "Great! Now call the 'list_files' tool to see both of them.",
197
+ "Excellent. Now delete both files by calling the 'delete_file' tool for both.",
198
+ "Please call the 'list_files' tool one last time to make sure they are gone.",
199
+ "Perfect! Thank you so much."
200
+ ];
201
+
202
+ const messages: any[] = [
203
+ { role: 'system', content: 'You are an agentic file helper. You have access to tools to manage files in a sandbox directory. You MUST call the requested tools in every response to perform the file operations. Do not guess or assume results without executing the tool first. Wrap your tool calls exactly in <tool_call>...</tool_call> tags.' }
204
+ ];
205
+
206
+ try {
207
+ for (const userPrompt of conversationScenario) {
208
+ console.log(`\n👤 [User]: ${userPrompt}`);
209
+ messages.push({ role: 'user', content: userPrompt });
210
+
211
+ let agentTurnDone = false;
212
+ let loopLimiter = 0;
213
+
214
+ while (!agentTurnDone) {
215
+ loopLimiter++;
216
+ assert.ok(loopLimiter <= 10, 'Agent got stuck in an infinite tool calling loop');
217
+
218
+ // Add a 2.5 second delay to let the Qwen backend settle and prevent "The chat is in progress!" errors
219
+ await new Promise(resolve => setTimeout(resolve, 2500));
220
+
221
+ console.log(`[RealTest] Sending request to proxy completions endpoint (messages: ${messages.length})...`);
222
+
223
+ // Send request to real proxy completions API
224
+ const response = await fetch(`http://localhost:${port}/v1/chat/completions`, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({
228
+ model: 'qwen3.6-plus',
229
+ messages: messages,
230
+ tools: toolDefinitions,
231
+ stream: true
232
+ })
233
+ });
234
+
235
+ assert.strictEqual(response.status, 200, `Expected 200, got ${response.status}`);
236
+
237
+ const reader = response.body?.getReader();
238
+ assert.ok(reader, 'Response should have stream body');
239
+
240
+ const decoder = new TextDecoder();
241
+ let content = '';
242
+ let reasoning = '';
243
+ let toolCalls: any[] = [];
244
+ let buffer = '';
245
+
246
+ while (true) {
247
+ const { done, value } = await reader.read();
248
+ if (done) break;
249
+
250
+ buffer += decoder.decode(value, { stream: true });
251
+ const lines = buffer.split('\n');
252
+ buffer = lines.pop() || '';
253
+
254
+ for (const line of lines) {
255
+ const trimmed = line.trim();
256
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
257
+ const dataStr = trimmed.slice(6);
258
+ if (dataStr === '[DONE]') continue;
259
+
260
+ try {
261
+ const chunk = JSON.parse(dataStr);
262
+ if (chunk.choices && chunk.choices[0] && chunk.choices[0].delta) {
263
+ const delta = chunk.choices[0].delta;
264
+ if (delta.content) {
265
+ content += delta.content;
266
+ }
267
+ if (delta.reasoning_content) {
268
+ reasoning += delta.reasoning_content;
269
+ }
270
+ if (delta.tool_calls) {
271
+ for (const tc of delta.tool_calls) {
272
+ const idx = tc.index ?? 0;
273
+ if (!toolCalls[idx]) {
274
+ toolCalls[idx] = { id: tc.id, name: tc.function?.name || '', arguments: '' };
275
+ }
276
+ if (tc.function?.arguments) {
277
+ toolCalls[idx].arguments += tc.function.arguments;
278
+ }
279
+ }
280
+ }
281
+ }
282
+ } catch (err) {
283
+ // ignore partial chunk parsing errors
284
+ }
285
+ }
286
+ }
287
+
288
+ // Output and construct assistant message
289
+ const assistantMessage: any = { role: 'assistant' };
290
+ if (content) assistantMessage.content = content;
291
+ if (reasoning) assistantMessage.reasoning_content = reasoning;
292
+
293
+ if (reasoning) {
294
+ console.log(`💭 [Thinking]: ${reasoning}`);
295
+ }
296
+
297
+ if (toolCalls.length > 0) {
298
+ assistantMessage.tool_calls = toolCalls.map(tc => ({
299
+ id: tc.id,
300
+ type: 'function',
301
+ function: {
302
+ name: tc.name,
303
+ arguments: tc.arguments
304
+ }
305
+ }));
306
+
307
+ messages.push(assistantMessage);
308
+
309
+ for (const tc of assistantMessage.tool_calls) {
310
+ const toolName = tc.function.name as keyof typeof localTools;
311
+ const toolArgs = JSON.parse(tc.function.arguments || '{}');
312
+ console.log(`🛠️ [Tool Call]: ${toolName} with args:`, toolArgs);
313
+
314
+ let result = '';
315
+ if (typeof localTools[toolName] === 'function') {
316
+ try {
317
+ result = localTools[toolName](toolArgs as any);
318
+ } catch (err: any) {
319
+ result = JSON.stringify({ error: `Tool execution failed: ${err.message}` });
320
+ }
321
+ } else {
322
+ result = JSON.stringify({
323
+ error: `Tool '${toolName}' is not available. Please use one of the available tools: list_files, create_file, read_file, edit_file, delete_file.`
324
+ });
325
+ }
326
+ console.log(`🟢 [Tool Result]: ${result}`);
327
+
328
+ messages.push({
329
+ role: 'tool',
330
+ tool_call_id: tc.id,
331
+ name: toolName,
332
+ content: result
333
+ });
334
+ }
335
+ } else {
336
+ console.log(`🤖 [Agent]: ${content}`);
337
+ messages.push(assistantMessage);
338
+ agentTurnDone = true;
339
+ }
340
+ }
341
+ }
342
+
343
+ console.log(`\n[RealTest] Integration Test complete! Total chat history size: ${messages.length} messages.`);
344
+ assert.ok(messages.length > 30, `Expected conversation history to contain >30 messages, got ${messages.length}`);
345
+
346
+ // Sandbox must be clean at the end
347
+ const remainingFiles = fs.readdirSync(SANDBOX_DIR);
348
+ assert.strictEqual(remainingFiles.length, 0, 'Sandbox directory must be empty at the end');
349
+
350
+ } finally {
351
+ // Teardown browser context and Hono server
352
+ await closePlaywright();
353
+ if (server) {
354
+ server.close();
355
+ console.log('[RealTest] Server stopped and Playwright closed.');
356
+ } else {
357
+ console.log('[RealTest] Playwright closed.');
358
+ }
359
+ }
360
+ });
@@ -0,0 +1,103 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ process.env.TEST_MOCK_PLAYWRIGHT = 'true';
5
+
6
+ import { app } from '../api/server.js';
7
+ import { initPlaywright, closePlaywright } from '../services/playwright.ts';
8
+
9
+ test('Concurrent requests are serialized by mutex', async () => {
10
+ const originalFetch = globalThis.fetch;
11
+
12
+ globalThis.fetch = async (input: any) => {
13
+ const url = typeof input === 'string' ? input : input.url;
14
+ if (url.includes('/api/models')) {
15
+ return new Response(JSON.stringify({
16
+ data: [{ id: 'qwen3.6-plus', owned_by: 'qwen', info: { created_at: Date.now(), meta: {} } }]
17
+ }), { status: 200 });
18
+ }
19
+ if (url.includes('/api/v2/chat/completions')) {
20
+ return new Response(
21
+ 'data: {"choices": [{"delta": {"phase": "answer", "content": "OK"}}]}\n\ndata: [DONE]\n\n',
22
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
23
+ );
24
+ }
25
+ return originalFetch(input);
26
+ };
27
+
28
+ await initPlaywright(false);
29
+
30
+ try {
31
+ const promises = Array.from({ length: 5 }, (_, i) =>
32
+ app.fetch(
33
+ new Request('http://localhost/v1/chat/completions', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({
37
+ model: 'qwen3.6-plus',
38
+ messages: [{ role: 'user', content: `Request ${i}` }],
39
+ stream: false
40
+ })
41
+ })
42
+ )
43
+ );
44
+
45
+ const responses = await Promise.all(promises);
46
+
47
+ // All requests should complete (serialized by mutex)
48
+ for (const res of responses) {
49
+ assert.ok(
50
+ res.status === 200 || res.status === 429 || res.status === 502,
51
+ `Unexpected status: ${res.status}`
52
+ );
53
+ }
54
+ } finally {
55
+ globalThis.fetch = originalFetch;
56
+ await closePlaywright();
57
+ }
58
+ });
59
+
60
+ test('No-thinking model variant is accepted', async () => {
61
+ const originalFetch = globalThis.fetch;
62
+
63
+ globalThis.fetch = async (input: any) => {
64
+ const url = typeof input === 'string' ? input : input.url;
65
+ if (url.includes('/api/models')) {
66
+ return new Response(JSON.stringify({
67
+ data: [{ id: 'qwen3.6-plus', owned_by: 'qwen', info: { created_at: Date.now(), meta: { max_context_length: 1000000 } } }]
68
+ }), { status: 200 });
69
+ }
70
+ if (url.includes('/api/v2/chat/completions')) {
71
+ return new Response(
72
+ 'data: {"choices": [{"delta": {"phase": "answer", "content": "OK"}}]}\n\ndata: [DONE]\n\n',
73
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
74
+ );
75
+ }
76
+ return originalFetch(input);
77
+ };
78
+
79
+ await initPlaywright(false);
80
+
81
+ try {
82
+ // Test no-thinking model is accepted without error
83
+ const res = await app.fetch(
84
+ new Request('http://localhost/v1/chat/completions', {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({
88
+ model: 'qwen3.6-plus-no-thinking',
89
+ messages: [{ role: 'user', content: 'Test' }],
90
+ stream: false
91
+ })
92
+ })
93
+ );
94
+
95
+ assert.ok(
96
+ res.status === 200 || res.status === 429 || res.status === 502,
97
+ `No-thinking model should be accepted, got status: ${res.status}`
98
+ );
99
+ } finally {
100
+ globalThis.fetch = originalFetch;
101
+ await closePlaywright();
102
+ }
103
+ });