@pedrofariasx/qwenproxy 1.2.1 → 1.2.3

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 (41) hide show
  1. package/README.md +3 -13
  2. package/package.json +1 -1
  3. package/src/api/server.ts +4 -6
  4. package/src/cache/memory-cache.ts +5 -3
  5. package/src/core/account-manager.ts +1 -1
  6. package/src/core/accounts.ts +1 -1
  7. package/src/login.ts +2 -2
  8. package/src/routes/chat.ts +122 -91
  9. package/src/routes/upload.ts +5 -5
  10. package/src/services/playwright.ts +40 -120
  11. package/src/services/qwen.ts +29 -27
  12. package/src/tests/concurrency.test.ts +1 -1
  13. package/src/tests/concurrentChat.test.ts +1 -1
  14. package/src/tests/contextTruncation.test.ts +142 -0
  15. package/src/tests/delta.test.ts +80 -10
  16. package/src/tests/jsonFix.test.ts +110 -98
  17. package/src/tests/multimodal.test.ts +1 -1
  18. package/src/tests/parser.test.ts +40 -2
  19. package/src/tools/parser.ts +98 -33
  20. package/src/utils/context-truncation.ts +1 -6
  21. package/src/utils/json.ts +9 -8
  22. package/src/utils/types.ts +1 -1
  23. package/src/linter/extraction-engine.ts +0 -165
  24. package/src/linter/index.ts +0 -258
  25. package/src/linter/repair-normalize.ts +0 -245
  26. package/src/linter/safety-gate.ts +0 -219
  27. package/src/linter/streaming-state-machine.ts +0 -252
  28. package/src/linter/structural-parser.ts +0 -352
  29. package/src/linter/types.ts +0 -74
  30. package/src/tests/linter.test.ts +0 -151
  31. package/src/tests/parallel.test.ts +0 -42
  32. package/src/tests/structureVerification.test.ts +0 -176
  33. package/src/tools/ast.ts +0 -15
  34. package/src/tools/coercion.ts +0 -67
  35. package/src/tools/confidence.ts +0 -48
  36. package/src/tools/detector.ts +0 -40
  37. package/src/tools/executor.ts +0 -236
  38. package/src/tools/pipeline.ts +0 -122
  39. package/src/tools/registry-runtime.ts +0 -34
  40. package/src/tools/repair.ts +0 -42
  41. package/src/tools/validator.ts +0 -33
@@ -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,110 @@
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
+ });
@@ -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);
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { StreamingToolParser } from '../tools/parser.ts';
3
+ import { StreamingToolParser } from '../tools/parser.js';
4
4
 
5
5
  test('StreamingToolParser: basic tool call', () => {
6
6
  const parser = new StreamingToolParser();
@@ -54,7 +54,7 @@ test('StreamingToolParser: flush partial content', () => {
54
54
  test('StreamingToolParser: robust parsing of malformed JSON', () => {
55
55
  const parser = new StreamingToolParser();
56
56
 
57
- const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1</tool_call>');
57
+ const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1}</tool_call>');
58
58
  assert.strictEqual(res.toolCalls.length, 1);
59
59
  assert.strictEqual(res.toolCalls[0].name, 'broken');
60
60
  assert.deepStrictEqual(res.toolCalls[0].arguments, { a: 1 });
@@ -87,3 +87,41 @@ test('StreamingToolParser: handles multiple tool calls in array format', () => {
87
87
  assert.strictEqual(result.toolCalls[1].name, 'read');
88
88
  assert.strictEqual(result.toolCalls[0].arguments.command, 'ls');
89
89
  });
90
+
91
+ test('StreamingToolParser: double-escaped quotes in JSON', () => {
92
+ const parser = new StreamingToolParser();
93
+
94
+ const input = '<tool_call>{\\"name\\": \\"edit\\", \\"arguments\\": {\\"filePath\\": \\"/tmp/test.txt\\", \\"content\\": \\"hello\\"}}</tool_call>';
95
+ const res = parser.feed(input);
96
+ assert.strictEqual(res.toolCalls.length, 1);
97
+ assert.strictEqual(res.toolCalls[0].name, 'edit');
98
+ assert.strictEqual(res.toolCalls[0].arguments.filePath, '/tmp/test.txt');
99
+ });
100
+
101
+ test('StreamingToolParser: double-escaped quotes in XML parameters', () => {
102
+ const parser = new StreamingToolParser();
103
+
104
+ const input = '<tool_call>\n<name>write</name>\n<parameter name=\\"content\\">&lt;div&gt;hello &amp; world&lt;/div&gt;</parameter>\n</tool_call>';
105
+ const res = parser.feed(input);
106
+ assert.strictEqual(res.toolCalls.length, 1);
107
+ assert.strictEqual(res.toolCalls[0].name, 'write');
108
+ assert.strictEqual(res.toolCalls[0].arguments.content, '<div>hello & world</div>');
109
+ });
110
+
111
+ test('StreamingToolParser: truncated JSON with unclosed string', () => {
112
+ const parser = new StreamingToolParser();
113
+
114
+ const res = parser.feed('<tool_call>{"name": "bash", "arguments": {"command": "echo hello</tool_call>');
115
+ assert.strictEqual(res.toolCalls.length, 1);
116
+ assert.strictEqual(res.toolCalls[0].name, 'bash');
117
+ assert.strictEqual(typeof res.toolCalls[0].arguments.command, 'string');
118
+ });
119
+
120
+ test('StreamingToolParser: flush double-escaped tool call', () => {
121
+ const parser = new StreamingToolParser();
122
+
123
+ parser.feed('<tool_call>{\\"name\\": \\"recover\\",\\"arguments\\": {\\"a\\": \\"val');
124
+ const flushed = parser.flush();
125
+ assert.strictEqual(flushed.toolCalls.length, 1);
126
+ assert.strictEqual(flushed.toolCalls[0].name, 'recover');
127
+ });
@@ -5,7 +5,7 @@
5
5
  * Supports both JSON and Hermes-style XML <parameter> formats.
6
6
  */
7
7
 
8
- import { v4 as uuidv4 } from 'uuid';
8
+ import crypto from 'crypto';
9
9
  import { robustParseJSON } from '../utils/json.js';
10
10
  import { logger } from '../core/logger.js';
11
11
  import type { ParsedToolCall } from './types';
@@ -30,6 +30,29 @@ function decodeXmlEntities(value: string): string {
30
30
  .replace(/&amp;/g, '&');
31
31
  }
32
32
 
33
+ function unescapeDoubleEscaped(content: string): string {
34
+ const trimmed = content.trim();
35
+ if (!trimmed) return content;
36
+
37
+ const isJsonLike = trimmed.startsWith('{') || trimmed.startsWith('[');
38
+ const isXmlLike = trimmed.startsWith('<');
39
+
40
+ if (!isJsonLike && !isXmlLike) return content;
41
+
42
+ const firstQuoteIdx = trimmed.indexOf('"');
43
+ if (firstQuoteIdx === -1) return content;
44
+
45
+ const firstEscapedQuoteIdx = trimmed.indexOf('\\"');
46
+
47
+ if (firstEscapedQuoteIdx !== -1 && (firstQuoteIdx === -1 || firstEscapedQuoteIdx < firstQuoteIdx)) {
48
+ return content
49
+ .replace(/\\"/g, '"')
50
+ .replace(/\\\\/g, '\\');
51
+ }
52
+
53
+ return content;
54
+ }
55
+
33
56
  function coerceParameterValue(rawValue: string): unknown {
34
57
  const value = decodeXmlEntities(rawValue.trim());
35
58
  if (value === 'true') return true;
@@ -136,19 +159,67 @@ function parseRecoverableXmlToolCall(
136
159
  return { name: toolName, arguments: args };
137
160
  }
138
161
 
162
+ // ─── String-Aware Tag Detection ─────────────────────────────────────────────────
163
+
164
+ function findToolEndIndex(buffer: string): number {
165
+ const tagLen = TOOL_END.length;
166
+ const limit = buffer.length - tagLen;
167
+ let inString = false;
168
+ let escaped = false;
169
+
170
+ for (let i = 0; i <= limit; i++) {
171
+ const ch = buffer[i];
172
+ if (escaped) { escaped = false; continue; }
173
+ if (ch === '\\') { escaped = true; continue; }
174
+ if (ch === '"') { inString = !inString; continue; }
175
+ if (inString || ch !== '<') continue;
176
+ let match = true;
177
+ for (let j = 1; j < tagLen; j++) {
178
+ const c = buffer.charCodeAt(i + j);
179
+ const t = TOOL_END.charCodeAt(j);
180
+ if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
181
+ }
182
+ if (match) return i;
183
+ }
184
+ return -1;
185
+ }
186
+
139
187
  // ─── Partial Tag Detection ─────────────────────────────────────────────────────
140
188
 
141
189
  const TOOL_START_LITERAL = '<tool_call>';
142
190
 
143
191
  function findPartialToolOpenIndex(buffer: string): number {
144
- const lower = buffer.toLowerCase();
145
- // Check if there's a partial opening tag like `<tool_call` without closing `>`
146
- const idx = lower.lastIndexOf('<tool_call');
147
- if (idx !== -1 && lower.indexOf('>', idx) === -1) return idx;
192
+ const prefix = '<tool_call';
193
+ const prefixLen = prefix.length;
194
+ const bufLen = buffer.length;
195
+
196
+ let lastPartialIdx = -1;
197
+ for (let i = bufLen - 1; i >= Math.max(0, bufLen - prefixLen - 1); i--) {
198
+ if (buffer[i] !== '<') continue;
199
+ let match = true;
200
+ for (let j = 1; j < prefixLen && i + j < bufLen; j++) {
201
+ const c = buffer.charCodeAt(i + j);
202
+ const t = prefix.charCodeAt(j);
203
+ if (c !== t && ((c | 0x20) !== (t | 0x20))) { match = false; break; }
204
+ }
205
+ if (match) {
206
+ lastPartialIdx = i;
207
+ break;
208
+ }
209
+ }
210
+ if (lastPartialIdx !== -1 && buffer.indexOf('>', lastPartialIdx) === -1) return lastPartialIdx;
148
211
 
149
- // Check for partial prefix at end (e.g. `<tool`, `<tool_`, `<tool_c`)
150
212
  for (let i = 1; i < TOOL_START_LITERAL.length; i++) {
151
- if (lower.endsWith(TOOL_START_LITERAL.substring(0, i))) return buffer.length - i;
213
+ const sub = TOOL_START_LITERAL.substring(0, i);
214
+ const subLen = sub.length;
215
+ if (bufLen < subLen) continue;
216
+ let match = true;
217
+ for (let j = 0; j < subLen; j++) {
218
+ const c = buffer.charCodeAt(bufLen - subLen + j);
219
+ const t = sub.charCodeAt(j);
220
+ if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
221
+ }
222
+ if (match) return bufLen - i;
152
223
  }
153
224
  return -1;
154
225
  }
@@ -183,6 +254,11 @@ export class StreamingToolParser {
183
254
 
184
255
  while (this.buffer.length > 0) {
185
256
  if (!this.insideTool) {
257
+ if (this.buffer.indexOf('<') === -1) {
258
+ if (this.emittedToolCallCount === 0) result.text += this.buffer;
259
+ this.buffer = '';
260
+ break;
261
+ }
186
262
  const match = this.buffer.match(TOOL_OPEN_RE);
187
263
  if (match && match.index !== undefined) {
188
264
  // Text before the tool call tag
@@ -206,10 +282,8 @@ export class StreamingToolParser {
206
282
  }
207
283
  break;
208
284
  }
209
- } else {
210
- // Inside tool: look for </tool_call>
211
- const lowerBuffer = this.buffer.toLowerCase();
212
- const endIdx = lowerBuffer.indexOf(TOOL_END);
285
+ } else {
286
+ const endIdx = this.buffer.indexOf(TOOL_END);
213
287
  if (endIdx !== -1) {
214
288
  const content = this.buffer.substring(0, endIdx);
215
289
  this.buffer = this.buffer.substring(endIdx + TOOL_END.length);
@@ -276,20 +350,11 @@ export class StreamingToolParser {
276
350
  return this.insideTool;
277
351
  }
278
352
 
279
- /**
280
- * Get any lead-in text that was captured before tool calls.
281
- * Useful for fallback content when tool calls fail to parse.
282
- */
283
- getPendingLeadIn(): string {
284
- return this.pendingLeadIn;
285
- }
286
-
287
353
  // ─── Internal Methods ──────────────────────────────────────────────────────
288
354
 
289
355
  private processToolContent(content: string, result: ParserResult): void {
290
- const t = content.trim();
356
+ let t = content.trim();
291
357
  if (!t) {
292
- // Empty tool call - malformed. Restore lead-in if possible.
293
358
  logger.warn('[parser] Dropping empty tool call block');
294
359
  if (this.emittedToolCallCount === 0 && this.pendingLeadIn.trim().length > 0) {
295
360
  result.text += this.pendingLeadIn;
@@ -298,11 +363,12 @@ export class StreamingToolParser {
298
363
  return;
299
364
  }
300
365
 
301
- // 1) Try Hermes-style XML <parameter> format first
366
+ t = unescapeDoubleEscaped(t);
367
+
302
368
  const xmlParsed = parseXmlParameterToolCall(t, this.currentOpenTag, this.tools);
303
369
  if (xmlParsed) {
304
370
  result.toolCalls.push({
305
- id: `call_${uuidv4()}`,
371
+ id: `call_${crypto.randomUUID()}`,
306
372
  name: xmlParsed.name,
307
373
  arguments: xmlParsed.arguments,
308
374
  });
@@ -361,31 +427,30 @@ export class StreamingToolParser {
361
427
  }
362
428
 
363
429
  private tryRecoverToolCall(block: string): ParsedToolCall | null {
364
- // Try full parse first
365
- const xmlParsed = parseXmlParameterToolCall(block, this.currentOpenTag, this.tools);
430
+ const unescaped = unescapeDoubleEscaped(block);
431
+
432
+ const xmlParsed = parseXmlParameterToolCall(unescaped, this.currentOpenTag, this.tools);
366
433
  if (xmlParsed) {
367
434
  return {
368
- id: `call_${uuidv4()}`,
435
+ id: `call_${crypto.randomUUID()}`,
369
436
  name: xmlParsed.name,
370
437
  arguments: xmlParsed.arguments,
371
438
  };
372
439
  }
373
440
 
374
- // Try recoverable (unclosed parameters)
375
- const recovered = parseRecoverableXmlToolCall(block, this.currentOpenTag, this.tools);
441
+ const recovered = parseRecoverableXmlToolCall(unescaped, this.currentOpenTag, this.tools);
376
442
  if (recovered) {
377
443
  return {
378
- id: `call_${uuidv4()}`,
444
+ id: `call_${crypto.randomUUID()}`,
379
445
  name: recovered.name,
380
446
  arguments: recovered.arguments,
381
447
  };
382
448
  }
383
449
 
384
- // Try JSON (single or multiple)
385
- const jsonParsed = this.parseToolContent(block);
450
+ const jsonParsed = this.parseToolContent(unescaped);
386
451
  if (jsonParsed.length > 0) {
387
452
  const first = jsonParsed[0];
388
- const attrName = extractToolName(this.currentOpenTag, block);
453
+ const attrName = extractToolName(this.currentOpenTag, unescaped);
389
454
  if (attrName && !first.name) first.name = attrName;
390
455
  if (first.name) return first;
391
456
  }
@@ -438,7 +503,7 @@ export class StreamingToolParser {
438
503
  if (typeof args !== 'object' || args === null) args = {};
439
504
 
440
505
  return {
441
- id: parsed.id || parsed.tool_call_id || `call_${uuidv4()}`,
506
+ id: parsed.id || parsed.tool_call_id || `call_${crypto.randomUUID()}`,
442
507
  name,
443
508
  arguments: args,
444
509
  };