@pedrofariasx/qwenproxy 1.2.2 → 1.3.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.
@@ -1,7 +1,10 @@
1
- import { getQwenHeaders, getBasicHeaders } from './playwright.ts';
1
+ import { getQwenHeaders, getBasicHeaders } from './playwright.js';
2
+ import { MAX_PAYLOAD_SIZE } from '../core/model-registry.js';
2
3
  import crypto from 'crypto';
3
4
 
4
5
  const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
6
+ const BASE_TIMEOUT_MS = 120000;
7
+ const TIMEOUT_PER_MB = 30000;
5
8
 
6
9
  export class RetryableQwenStreamError extends Error {
7
10
  readonly retryAfterMs: number;
@@ -28,8 +31,7 @@ interface SessionEntry {
28
31
  timestamp: number;
29
32
  }
30
33
 
31
- const sessionStates: Map<string, SessionEntry> = (globalThis as any)._sessionStates || new Map();
32
- (globalThis as any)._sessionStates = sessionStates;
34
+ const sessionStates: Map<string, SessionEntry> = new Map();
33
35
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
34
36
 
35
37
  function cleanupStaleSessions() {
@@ -65,11 +67,9 @@ interface WarmPoolEntry {
65
67
  timestamp: number;
66
68
  }
67
69
 
68
- const warmPool: Map<string, WarmPoolEntry[]> = (globalThis as any)._warmPool || new Map();
69
- (globalThis as any)._warmPool = warmPool;
70
+ const warmPool: Map<string, WarmPoolEntry[]> = new Map();
70
71
 
71
- const refillPromises: Map<string, Promise<void>> = (globalThis as any)._refillPromises || new Map();
72
- (globalThis as any)._refillPromises = refillPromises;
72
+ const refillPromises: Map<string, Promise<void>> = new Map();
73
73
 
74
74
  const WARM_POOL_SIZE = 5;
75
75
  const WARM_POOL_TTL_MS = 10 * 60 * 1000;
@@ -78,9 +78,8 @@ function cleanupStalePool(accountId: string) {
78
78
  const pool = warmPool.get(accountId);
79
79
  if (!pool) return;
80
80
  const now = Date.now();
81
- for (let i = pool.length - 1; i >= 0; i--) {
82
- if (now - pool[i].timestamp > WARM_POOL_TTL_MS) pool.splice(i, 1);
83
- }
81
+ const filtered = pool.filter(e => now - e.timestamp <= WARM_POOL_TTL_MS);
82
+ if (filtered.length !== pool.length) warmPool.set(accountId, filtered);
84
83
  }
85
84
 
86
85
  async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
@@ -93,8 +92,6 @@ async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, s
93
92
  }
94
93
 
95
94
  async function createRealQwenChat(header: Record<string, string>): Promise<string> {
96
- const controller = new AbortController();
97
- const timeoutId = setTimeout(() => controller.abort(), 30000);
98
95
  const response = await fetch('https://chat.qwen.ai/api/v2/chats/new', {
99
96
  method: 'POST',
100
97
  headers: {
@@ -116,9 +113,8 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
116
113
  timestamp: Date.now(),
117
114
  project_id: '',
118
115
  }),
119
- signal: controller.signal,
116
+ signal: AbortSignal.timeout(30000),
120
117
  });
121
- clearTimeout(timeoutId);
122
118
 
123
119
  if (!response.ok) throw new Error(`Failed to create chat: ${response.status}`);
124
120
  const json = await response.json();
@@ -169,7 +165,15 @@ export async function getWarmedChat(accountId?: string) {
169
165
  }
170
166
  await refillPromises.get(key);
171
167
  }
172
- if (pool.length === 0) throw new Error(`Warm pool empty for ${key}`);
168
+ if (pool.length === 0) {
169
+ // Retry once with short backoff if pool is still empty after first refill attempt
170
+ await new Promise(r => setTimeout(r, 1000));
171
+ if (!refillPromises.has(key)) {
172
+ refillPromises.set(key, refillPoolForAccount(key).finally(() => refillPromises.delete(key)));
173
+ }
174
+ await refillPromises.get(key);
175
+ }
176
+ if (pool.length === 0) throw new Error(`Warm pool empty after retry for ${key}`);
173
177
  return pool.shift()!;
174
178
  }
175
179
 
@@ -402,6 +406,7 @@ export async function createQwenStream(
402
406
  }
403
407
  } catch (err: any) {
404
408
  console.error('[Qwen] Failed to process multimodal uploads:', err.message);
409
+ throw new Error(`Multimodal upload failed: ${err.message}`);
405
410
  }
406
411
  }
407
412
 
@@ -450,9 +455,17 @@ export async function createQwenStream(
450
455
  timestamp: timestamp + 1
451
456
  };
452
457
 
458
+ const payloadJson = JSON.stringify(payload);
459
+ const payloadSize = Buffer.byteLength(payloadJson);
460
+ if (payloadSize > MAX_PAYLOAD_SIZE) {
461
+ throw new Error(`Payload too large: ${payloadSize} bytes exceeds limit of ${MAX_PAYLOAD_SIZE} bytes`);
462
+ }
463
+ const payloadMB = payloadSize / (1024 * 1024);
464
+ const timeoutMs = BASE_TIMEOUT_MS + Math.ceil(payloadMB * TIMEOUT_PER_MB);
465
+
453
466
  const url = `https://chat.qwen.ai/api/v2/chat/completions?chat_id=${chatId}`;
454
467
  const controller = new AbortController();
455
- const timeoutId = setTimeout(() => controller.abort(), 120000);
468
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
456
469
  const response = await fetch(url, {
457
470
  method: 'POST',
458
471
  headers: {
@@ -471,7 +484,7 @@ export async function createQwenStream(
471
484
  'x-request-id': crypto.randomUUID(),
472
485
  'bx-v': chatHeaders['bx-v'],
473
486
  },
474
- body: JSON.stringify(payload),
487
+ body: payloadJson,
475
488
  signal: controller.signal
476
489
  });
477
490
  clearTimeout(timeoutId);
@@ -4,7 +4,7 @@ import assert from 'node:assert';
4
4
  process.env.TEST_MOCK_PLAYWRIGHT = 'true';
5
5
 
6
6
  import { app } from '../api/server.js';
7
- import { initPlaywright, closePlaywright } from '../services/playwright.ts';
7
+ import { initPlaywright, closePlaywright } from '../services/playwright.js';
8
8
 
9
9
  test('Concurrent requests are serialized by mutex', async () => {
10
10
  const originalFetch = globalThis.fetch;
@@ -3,7 +3,7 @@ import assert from 'node:assert';
3
3
  import net from 'node:net';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { app } from '../api/server.js';
6
- import { initPlaywright, closePlaywright } from '../services/playwright.ts';
6
+ import { initPlaywright, closePlaywright } from '../services/playwright.js';
7
7
 
8
8
  function isPortAvailable(port: number): Promise<boolean> {
9
9
  return new Promise((resolve) => {
@@ -0,0 +1,142 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { estimateTokenCount, truncateMessages } from '../utils/context-truncation.js';
4
+
5
+ test('estimateTokenCount: returns 0 for empty string', () => {
6
+ assert.strictEqual(estimateTokenCount(''), 0);
7
+ });
8
+
9
+ test('estimateTokenCount: estimates tokens conservatively using 2.5 divisor', () => {
10
+ assert.strictEqual(estimateTokenCount('hello'), 2);
11
+ assert.strictEqual(estimateTokenCount('a'.repeat(100)), 40);
12
+ assert.strictEqual(estimateTokenCount('a'.repeat(250)), 100);
13
+ assert.strictEqual(estimateTokenCount('a'.repeat(2500)), 1000);
14
+ });
15
+
16
+ test('estimateTokenCount: handles single character', () => {
17
+ assert.strictEqual(estimateTokenCount('x'), 1);
18
+ });
19
+
20
+ test('estimateTokenCount: rounds up for non-multiples of 2.5', () => {
21
+ assert.strictEqual(estimateTokenCount('ab'), 1);
22
+ assert.strictEqual(estimateTokenCount('abc'), 2);
23
+ assert.strictEqual(estimateTokenCount('abcd'), 2);
24
+ });
25
+
26
+ test('truncateMessages: returns all messages when within context window', () => {
27
+ const messages = [
28
+ { role: 'system', content: 'You are helpful.' },
29
+ { role: 'user', content: 'Hello' },
30
+ { role: 'assistant', content: 'Hi there!' },
31
+ ];
32
+ const result = truncateMessages(messages, 100000);
33
+ assert.strictEqual(result.length, 3);
34
+ assert.strictEqual(result[0].content, 'You are helpful.');
35
+ assert.strictEqual(result[1].content, 'Hello');
36
+ assert.strictEqual(result[2].content, 'Hi there!');
37
+ });
38
+
39
+ test('truncateMessages: preserves chronological order', () => {
40
+ const messages = [
41
+ { role: 'user', content: 'first' },
42
+ { role: 'assistant', content: 'second' },
43
+ { role: 'user', content: 'third' },
44
+ ];
45
+ const result = truncateMessages(messages, 100000);
46
+ assert.strictEqual(result[0].role, 'user');
47
+ assert.strictEqual(result[0].content, 'first');
48
+ assert.strictEqual(result[1].role, 'assistant');
49
+ assert.strictEqual(result[2].role, 'user');
50
+ assert.strictEqual(result[2].content, 'third');
51
+ });
52
+
53
+ test('truncateMessages: drops oldest messages first when exceeding context', () => {
54
+ const largeContent = 'x'.repeat(5000);
55
+ const messages = [
56
+ { role: 'user', content: largeContent },
57
+ { role: 'assistant', content: largeContent },
58
+ { role: 'user', content: 'latest message' },
59
+ ];
60
+ const result = truncateMessages(messages, 2000);
61
+ const lastMsg = result[result.length - 1];
62
+ assert.ok(lastMsg.content.includes('latest message') || lastMsg.content.includes('[Truncated]'));
63
+ });
64
+
65
+ test('truncateMessages: returns system prompt as fallback when context is extremely small', () => {
66
+ const messages = [
67
+ { role: 'user', content: 'some content' },
68
+ ];
69
+ const systemPrompt = 'system instructions';
70
+ const result = truncateMessages(messages, 10, systemPrompt);
71
+ assert.strictEqual(result.length, 1);
72
+ assert.strictEqual(result[0].role, 'user');
73
+ assert.strictEqual(result[0].content, systemPrompt);
74
+ });
75
+
76
+ test('truncateMessages: handles array content in messages', () => {
77
+ const messages = [
78
+ {
79
+ role: 'user',
80
+ content: [
81
+ { type: 'text', text: 'hello' },
82
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
83
+ ],
84
+ },
85
+ ];
86
+ const result = truncateMessages(messages, 100000);
87
+ assert.strictEqual(result.length, 1);
88
+ assert.ok(result[0].content.includes('hello'));
89
+ });
90
+
91
+ test('truncateMessages: handles null content', () => {
92
+ const messages = [
93
+ { role: 'user', content: null },
94
+ { role: 'assistant', content: 'response' },
95
+ ];
96
+ const result = truncateMessages(messages, 100000);
97
+ assert.strictEqual(result.length, 2);
98
+ assert.strictEqual(result[0].content, '');
99
+ assert.strictEqual(result[1].content, 'response');
100
+ });
101
+
102
+ test('truncateMessages: handles object content', () => {
103
+ const messages = [
104
+ { role: 'user', content: { structured: 'data', value: 42 } },
105
+ ];
106
+ const result = truncateMessages(messages, 100000);
107
+ assert.strictEqual(result.length, 1);
108
+ assert.ok(result[0].content.includes('structured'));
109
+ });
110
+
111
+ test('truncateMessages: truncates partially fitting message with marker', () => {
112
+ const messages = [
113
+ { role: 'user', content: 'a'.repeat(10000) },
114
+ ];
115
+ const result = truncateMessages(messages, 1000);
116
+ assert.strictEqual(result.length, 1);
117
+ assert.ok(
118
+ result[0].content.includes('[Truncated]') || result[0].content.length < 10000,
119
+ 'Should truncate or mark as truncated'
120
+ );
121
+ });
122
+
123
+ test('truncateMessages: accounts for system prompt in available tokens', () => {
124
+ const systemPrompt = 'x'.repeat(2000);
125
+ const messages = [
126
+ { role: 'user', content: 'short' },
127
+ ];
128
+ const withSystem = truncateMessages(messages, 2000, systemPrompt);
129
+ const withoutSystem = truncateMessages(messages, 2000);
130
+ assert.ok(withSystem.length <= withoutSystem.length);
131
+ });
132
+
133
+ test('truncateMessages: handles empty messages array', () => {
134
+ const result = truncateMessages([], 100000);
135
+ assert.strictEqual(result.length, 0);
136
+ });
137
+
138
+ test('truncateMessages: handles empty messages with system prompt fallback', () => {
139
+ const result = truncateMessages([], 5, 'fallback');
140
+ assert.strictEqual(result.length, 1);
141
+ assert.strictEqual(result[0].content, 'fallback');
142
+ });
@@ -1,23 +1,20 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { getIncrementalDelta } from '../routes/chat.ts';
3
+ import { getIncrementalDelta } from '../routes/chat.js';
4
4
 
5
5
  test('getIncrementalDelta: handles strictly cumulative stream correctly', () => {
6
6
  let accumulated = '';
7
7
 
8
- // Step 1
9
8
  let chunk1 = 'const x = 1;';
10
9
  let res1 = getIncrementalDelta(accumulated, chunk1);
11
10
  assert.strictEqual(res1.delta, 'const x = 1;');
12
11
  accumulated = res1.matchedContent;
13
12
 
14
- // Step 2
15
13
  let chunk2 = 'const x = 1;\nconst y = 2;';
16
14
  let res2 = getIncrementalDelta(accumulated, chunk2);
17
15
  assert.strictEqual(res2.delta, '\nconst y = 2;');
18
16
  accumulated = res2.matchedContent;
19
17
 
20
- // Step 3
21
18
  let chunk3 = 'const x = 1;\nconst y = 2;\nconst z = 3;';
22
19
  let res3 = getIncrementalDelta(accumulated, chunk3);
23
20
  assert.strictEqual(res3.delta, '\nconst z = 3;');
@@ -29,19 +26,16 @@ test('getIncrementalDelta: handles strictly cumulative stream correctly', () =>
29
26
  test('getIncrementalDelta: handles strictly incremental stream correctly', () => {
30
27
  let accumulated = '';
31
28
 
32
- // Step 1
33
29
  let chunk1 = 'const x = 1;';
34
30
  let res1 = getIncrementalDelta(accumulated, chunk1);
35
31
  assert.strictEqual(res1.delta, 'const x = 1;');
36
32
  accumulated = res1.matchedContent;
37
33
 
38
- // Step 2
39
34
  let chunk2 = '\nconst y = 2;';
40
35
  let res2 = getIncrementalDelta(accumulated, chunk2);
41
36
  assert.strictEqual(res2.delta, '\nconst y = 2;');
42
37
  accumulated = res2.matchedContent;
43
38
 
44
- // Step 3
45
39
  let chunk3 = '\nconst z = 3;';
46
40
  let res3 = getIncrementalDelta(accumulated, chunk3);
47
41
  assert.strictEqual(res3.delta, '\nconst z = 3;');
@@ -51,13 +45,89 @@ test('getIncrementalDelta: handles strictly incremental stream correctly', () =>
51
45
  });
52
46
 
53
47
  test('getIncrementalDelta: does not suffer from false-positive repetitive word overlap bugs', () => {
54
- // Previously, if oldStr ended in a common keyword and newStr started/contained the same keyword,
55
- // it would incorrectly match them and strip them. Let's verify this is fixed.
56
48
  let accumulated = 'import { useState } from \'react\';\nimport {';
57
49
  let nextChunk = ' Button } from \'@/components/ui/button\';';
58
50
 
59
51
  let res = getIncrementalDelta(accumulated, nextChunk);
60
- // It should treat the next chunk as strictly incremental and return it unchanged.
61
52
  assert.strictEqual(res.delta, ' Button } from \'@/components/ui/button\';');
62
53
  assert.strictEqual(res.matchedContent, 'import { useState } from \'react\';\nimport { Button } from \'@/components/ui/button\';');
63
54
  });
55
+
56
+ test('getIncrementalDelta: empty oldStr returns newStr as delta', () => {
57
+ const res = getIncrementalDelta('', 'hello world');
58
+ assert.strictEqual(res.delta, 'hello world');
59
+ assert.strictEqual(res.matchedContent, 'hello world');
60
+ assert.strictEqual(res.contentLength, 11);
61
+ });
62
+
63
+ test('getIncrementalDelta: identical strings return empty delta', () => {
64
+ const str = 'some content here';
65
+ const res = getIncrementalDelta(str, str, str.length, str.slice(-64));
66
+ assert.strictEqual(res.delta, '');
67
+ assert.strictEqual(res.matchedContent, str);
68
+ });
69
+
70
+ test('getIncrementalDelta: completely different strings concatenate', () => {
71
+ const res = getIncrementalDelta('abc', 'xyz');
72
+ assert.strictEqual(res.delta, 'xyz');
73
+ assert.strictEqual(res.matchedContent, 'abcxyz');
74
+ });
75
+
76
+ test('getIncrementalDelta: uses prevLength fast path when suffix matches', () => {
77
+ const oldStr = 'hello world';
78
+ const prevLength = oldStr.length;
79
+ const prevSuffix = oldStr.slice(-64);
80
+ const newStr = 'hello world extended';
81
+
82
+ const res = getIncrementalDelta(oldStr, newStr, prevLength, prevSuffix);
83
+ assert.strictEqual(res.delta, ' extended');
84
+ assert.strictEqual(res.matchedContent, newStr);
85
+ assert.strictEqual(res.contentLength, newStr.length);
86
+ });
87
+
88
+ test('getIncrementalDelta: large string with tiny delta falls back to concatenation for safety', () => {
89
+ const oldStr = 'x'.repeat(3000);
90
+ const tinyDelta = 'a';
91
+ const newStr = oldStr + tinyDelta;
92
+
93
+ const res = getIncrementalDelta(oldStr, newStr, oldStr.length, oldStr.slice(-64));
94
+ assert.strictEqual(res.matchedContent, newStr);
95
+ });
96
+
97
+ test('getIncrementalDelta: contentSuffix tracks last 64 characters', () => {
98
+ const longStr = 'a'.repeat(100);
99
+ const res = getIncrementalDelta('', longStr);
100
+ assert.strictEqual(res.contentSuffix.length, 64);
101
+ assert.strictEqual(res.contentSuffix, 'a'.repeat(64));
102
+ });
103
+
104
+ test('getIncrementalDelta: handles segment-based prefix matching', () => {
105
+ const prefix = 'a'.repeat(200);
106
+ const oldStr = prefix + 'OLD';
107
+ const newStr = prefix + 'NEW';
108
+
109
+ const res = getIncrementalDelta(oldStr, newStr);
110
+ assert.ok(res.delta.length > 0);
111
+ });
112
+
113
+ test('getIncrementalDelta: works through a realistic multi-chunk stream', () => {
114
+ let accumulated = '';
115
+ const chunks = [
116
+ 'The',
117
+ 'The quick',
118
+ 'The quick brown',
119
+ 'The quick brown fox',
120
+ 'The quick brown fox jumps',
121
+ 'The quick brown fox jumps over the lazy dog.',
122
+ ];
123
+
124
+ let finalDelta = '';
125
+ for (const chunk of chunks) {
126
+ const res = getIncrementalDelta(accumulated, chunk, accumulated.length, accumulated.slice(-64));
127
+ finalDelta += res.delta;
128
+ accumulated = res.matchedContent;
129
+ }
130
+
131
+ assert.strictEqual(accumulated, 'The quick brown fox jumps over the lazy dog.');
132
+ assert.strictEqual(finalDelta, 'The quick brown fox jumps over the lazy dog.');
133
+ });
@@ -1,98 +1,127 @@
1
- import { robustParseJSON } from '../utils/json.ts';
2
-
3
- const problematicString = '{"name": "suggest", "arguments": {"suggest": "Landing page para escritório de advocacia criada em src/pages/Index.tsx", "actions": [{"label": "Revisar alterações", "description": "Executar review local das mudanças não commitadas", "prompt": "/local-review-uncommitted"}]})';
4
-
5
- console.log('Testing problematic string...');
6
- try {
7
- const result = robustParseJSON(problematicString);
8
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
9
- if (result.name === 'suggest' && result.arguments.actions.length === 1) {
10
- console.log(' Problematic string test passed!');
11
- } else {
12
- console.error('❌ Result structure is incorrect');
13
- }
14
- } catch (e) {
15
- console.error(' Failed to parse problematic string:', e);
16
- }
17
-
18
- const missingBraces = '{"name": "test", "arguments": {"foo": "bar"';
19
- console.log('\nTesting missing braces...');
20
- try {
21
- const result = robustParseJSON(missingBraces);
22
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
23
- if (result.arguments.foo === 'bar') {
24
- console.log(' Missing braces test passed!');
25
- } else {
26
- console.error('❌ Result structure is incorrect');
27
- }
28
- } catch (e) {
29
- console.error(' Failed to parse missing braces:', e);
30
- }
31
-
32
- const controlChars = '{"name": "control", "msg": "line 1\\nline 2"}';
33
- console.log('\nTesting control characters in string...');
34
- try {
35
- // Note: in a real string from the model, it would be a literal newline
36
- const literalNewline = '{"name": "control", "msg": "line 1\\nline 2"}'.replace('\\n', '\n');
37
- const result = robustParseJSON(literalNewline);
38
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
39
- if (result.msg.includes('line 1') && result.msg.includes('line 2')) {
40
- console.log('✅ Control characters test passed!');
41
- } else {
42
- console.error('❌ Result content is incorrect');
43
- }
44
- } catch (e) {
45
- console.error('❌ Failed to parse control characters:', e);
46
- }
47
-
48
- const crazyCase = `{"name": "suggest", "arguments": {"suggest": "Landing page criada para escritório de advocacia com design corporativo", "actions": [{"label": "Revisar código local", "description": "Exec<tool_call>\n{"name": "bashutar revisão local das", "arguments": alterações {"command": não commitadas", "npm run lint "prompt", "description":": "/local-review "Run lint-uncommitted"}] to verify code quality})"}}`;
49
- console.log('\nTesting crazy nested hallucination case...');
50
- try {
51
- const result = robustParseJSON(crazyCase);
52
- console.log('Successfully parsed (at least some of it):', JSON.stringify(result, null, 2));
53
- console.log('✅ Crazy case handled without crashing!');
54
- } catch (e: any) {
55
- console.log('⚠️ Crazy case failed (too malformed), but error was:', e.message);
56
- }
57
-
58
- const invalidEscapes = '{"path": "C:\\\\Users\\\\name\\\\Documents"}';
59
- console.log('\nTesting invalid backslash escapes in string...');
60
- try {
61
- const result = robustParseJSON(invalidEscapes);
62
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
63
- if (result.path === 'C:\\\\Users\\\\name\\\\Documents' || result.path === 'C:\\Users\\name\\Documents') {
64
- console.log(' Invalid backslash escapes test passed!');
65
- } else {
66
- console.error('❌ Result path is incorrect:', result.path);
67
- }
68
- } catch (e) {
69
- console.error('❌ Failed to parse invalid backslash escapes:', e);
70
- }
71
-
72
- const doubleKey = '{"name": "name": "create_file", "arguments": {"path": "b.txt"}}';
73
- console.log('\nTesting double key hallucination...');
74
- try {
75
- const result = robustParseJSON(doubleKey);
76
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
77
- if (result.name === 'create_file') {
78
- console.log('✅ Double key test passed!');
79
- } else {
80
- console.error('❌ Result name is incorrect:', result.name);
81
- }
82
- } catch (e) {
83
- console.error('❌ Failed to parse double key:', e);
84
- }
85
-
86
- const unquotedArgs = '{"name":"Read",arguments:{"file_path":"test.ts","limit":100}}';
87
- console.log('\nTesting unquoted arguments key...');
88
- try {
89
- const result = robustParseJSON(unquotedArgs);
90
- console.log('Successfully parsed:', JSON.stringify(result, null, 2));
91
- if (result.arguments && result.arguments.limit === 100) {
92
- console.log(' Unquoted arguments test passed!');
93
- } else {
94
- console.error(' Result structure is incorrect');
95
- }
96
- } catch (e) {
97
- console.error(' Failed to parse unquoted arguments:', e);
98
- }
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { robustParseJSON } from '../utils/json.js';
4
+
5
+ test('robustParseJSON: valid JSON passes through directly', () => {
6
+ const result = robustParseJSON('{"name": "test", "arguments": {"a": 1}}');
7
+ assert.deepStrictEqual(result, { name: 'test', arguments: { a: 1 } });
8
+ });
9
+
10
+ test('robustParseJSON: returns null for empty string', () => {
11
+ assert.strictEqual(robustParseJSON(''), null);
12
+ });
13
+
14
+ test('robustParseJSON: returns null for non-object string', () => {
15
+ assert.strictEqual(robustParseJSON('just plain text'), null);
16
+ });
17
+
18
+ test('robustParseJSON: handles markdown code fence wrapping', () => {
19
+ const result = robustParseJSON('```json\n{"name": "test"}\n```');
20
+ assert.deepStrictEqual(result, { name: 'test' });
21
+ });
22
+
23
+ test('robustParseJSON: handles missing closing braces', () => {
24
+ const result = robustParseJSON('{"name": "test", "arguments": {"foo": "bar"');
25
+ assert.ok(result);
26
+ assert.strictEqual(result.arguments.foo, 'bar');
27
+ });
28
+
29
+ test('robustParseJSON: handles missing closing brackets', () => {
30
+ const result = robustParseJSON('{"items": [1, 2, 3');
31
+ assert.ok(result);
32
+ assert.deepStrictEqual(result.items, [1, 2, 3]);
33
+ });
34
+
35
+ test('robustParseJSON: handles double key hallucination', () => {
36
+ const result = robustParseJSON('{"name": "name": "create_file", "arguments": {"path": "b.txt"}}');
37
+ assert.ok(result);
38
+ assert.strictEqual(result.name, 'create_file');
39
+ });
40
+
41
+ test('robustParseJSON: handles unquoted keys', () => {
42
+ const result = robustParseJSON('{"name":"Read",arguments:{"file_path":"test.ts","limit":100}}');
43
+ assert.ok(result);
44
+ assert.strictEqual(result.arguments.limit, 100);
45
+ });
46
+
47
+ test('robustParseJSON: handles control characters in string values', () => {
48
+ const literalNewline = '{"name": "control", "msg": "line 1\nline 2"}';
49
+ const result = robustParseJSON(literalNewline);
50
+ assert.ok(result);
51
+ assert.ok(result.msg.includes('line 1'));
52
+ assert.ok(result.msg.includes('line 2'));
53
+ });
54
+
55
+ test('robustParseJSON: handles Windows path backslashes', () => {
56
+ const result = robustParseJSON('{"path": "C:\\\\Users\\\\name\\\\Documents"}');
57
+ assert.ok(result);
58
+ assert.ok(
59
+ result.path === 'C:\\Users\\name\\Documents' || result.path === 'C:\\\\Users\\\\name\\\\Documents',
60
+ `Unexpected path: ${result.path}`
61
+ );
62
+ });
63
+
64
+ test('robustParseJSON: handles trailing comma', () => {
65
+ const result = robustParseJSON('{"name": "test", "value": 42,}');
66
+ assert.ok(result);
67
+ assert.strictEqual(result.name, 'test');
68
+ assert.strictEqual(result.value, 42);
69
+ });
70
+
71
+ test('robustParseJSON: handles complex nested suggest payload', () => {
72
+ const payload = '{"name": "suggest", "arguments": {"suggest": "Landing page criada", "actions": [{"label": "Revisar", "description": "Review", "prompt": "/local-review-uncommitted"}]})';
73
+ const result = robustParseJSON(payload);
74
+ assert.ok(result);
75
+ assert.strictEqual(result.name, 'suggest');
76
+ assert.ok(result.arguments.actions.length >= 1);
77
+ });
78
+
79
+ test('robustParseJSON: handles deeply nested malformed JSON gracefully', () => {
80
+ const crazy = `{"name": "suggest", "arguments": {"suggest": "ok", "actions": [{"label": "test"<tool_call>\n{"name": "broken"}]}}`;
81
+ const result = robustParseJSON(crazy);
82
+ assert.ok(result === null || typeof result === 'object');
83
+ });
84
+
85
+ test('robustParseJSON: strips leading text before first brace', () => {
86
+ const result = robustParseJSON('some text before {"name": "found"}');
87
+ assert.ok(result);
88
+ assert.strictEqual(result.name, 'found');
89
+ });
90
+
91
+ test('robustParseJSON: handles array values correctly', () => {
92
+ const result = robustParseJSON('{"items": ["a", "b", "c"]}');
93
+ assert.ok(result);
94
+ assert.deepStrictEqual(result.items, ['a', 'b', 'c']);
95
+ });
96
+
97
+ test('robustParseJSON: handles numeric values', () => {
98
+ const result = robustParseJSON('{"count": 42, "ratio": 3.14}');
99
+ assert.ok(result);
100
+ assert.strictEqual(result.count, 42);
101
+ assert.strictEqual(result.ratio, 3.14);
102
+ });
103
+
104
+ test('robustParseJSON: handles boolean and null values', () => {
105
+ const result = robustParseJSON('{"active": true, "deleted": false, "data": null}');
106
+ assert.ok(result);
107
+ assert.strictEqual(result.active, true);
108
+ assert.strictEqual(result.deleted, false);
109
+ assert.strictEqual(result.data, null);
110
+ });
111
+
112
+ test('robustParseJSON: handles unquoted string value after colon', () => {
113
+ const result = robustParseJSON('{"name": "bash", "arguments": {"command":export CI=true GIT_PAGER=cat npm run build, "description": "Build"}}');
114
+ assert.ok(result);
115
+ assert.strictEqual(result.name, 'bash');
116
+ assert.ok(typeof result.arguments.command === 'string');
117
+ assert.ok(result.arguments.command.includes('export CI=true'));
118
+ assert.strictEqual(result.arguments.description, 'Build');
119
+ });
120
+
121
+ test('robustParseJSON: handles unquoted string value with special chars', () => {
122
+ const result = robustParseJSON('{"name": "bash", "arguments": {"command":git add -A && git commit -m "fix"}}');
123
+ assert.ok(result);
124
+ assert.strictEqual(result.name, 'bash');
125
+ assert.ok(typeof result.arguments.command === 'string');
126
+ assert.ok(result.arguments.command.includes('git add'));
127
+ });
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import net from 'node:net';
7
7
  import { serve } from '@hono/node-server';
8
8
  import { app } from '../api/server.js';
9
- import { initPlaywright, closePlaywright } from '../services/playwright.ts';
9
+ import { initPlaywright, closePlaywright } from '../services/playwright.js';
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url);
12
12
  const __dirname = path.dirname(__filename);