@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.
- package/package.json +1 -1
- package/src/api/models.ts +3 -1
- package/src/api/server.ts +4 -4
- package/src/cache/memory-cache.ts +6 -5
- package/src/core/account-manager.ts +1 -1
- package/src/core/accounts.ts +1 -1
- package/src/core/model-registry.ts +56 -9
- package/src/login.ts +2 -2
- package/src/routes/chat.ts +20 -49
- package/src/routes/upload.ts +1 -1
- package/src/services/playwright.ts +42 -121
- package/src/services/qwen.ts +30 -17
- package/src/tests/concurrency.test.ts +1 -1
- package/src/tests/concurrentChat.test.ts +1 -1
- package/src/tests/contextTruncation.test.ts +142 -0
- package/src/tests/delta.test.ts +80 -10
- package/src/tests/jsonFix.test.ts +127 -98
- package/src/tests/multimodal.test.ts +1 -1
- package/src/tests/parser.test.ts +104 -24
- package/src/tools/parser.ts +94 -23
- package/src/utils/context-truncation.ts +13 -11
- package/src/utils/json.ts +130 -10
- package/src/utils/types.ts +1 -1
package/src/services/qwen.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { getQwenHeaders, getBasicHeaders } from './playwright.
|
|
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> =
|
|
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[]> =
|
|
69
|
-
(globalThis as any)._warmPool = warmPool;
|
|
70
|
+
const warmPool: Map<string, WarmPoolEntry[]> = new Map();
|
|
70
71
|
|
|
71
|
-
const refillPromises: Map<string, Promise<void>> =
|
|
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
|
-
|
|
82
|
-
|
|
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:
|
|
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)
|
|
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(),
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/tests/delta.test.ts
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { getIncrementalDelta } from '../routes/chat.
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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);
|