@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
@@ -11,11 +11,27 @@
11
11
  import { chromium, firefox, webkit, BrowserContext, Page } from 'playwright';
12
12
  import path from 'path';
13
13
  import crypto from 'crypto';
14
- import { QwenAccount } from '../core/accounts.ts';
15
- import { config } from '../core/config.ts';
14
+ import { QwenAccount } from '../core/accounts.js';
15
+ import { config } from '../core/config.js';
16
16
 
17
17
  export type BrowserType = 'chromium' | 'firefox' | 'webkit' | 'chrome' | 'edge';
18
18
 
19
+ interface BrowserEngineConfig {
20
+ engine: typeof chromium | typeof firefox | typeof webkit;
21
+ channel?: string;
22
+ }
23
+
24
+ function resolveBrowserEngine(browserType: BrowserType): BrowserEngineConfig {
25
+ switch (browserType) {
26
+ case 'firefox': return { engine: firefox };
27
+ case 'webkit': return { engine: webkit };
28
+ case 'chrome': return { engine: chromium, channel: 'chrome' };
29
+ case 'edge': return { engine: chromium, channel: 'msedge' };
30
+ case 'chromium':
31
+ default: return { engine: chromium };
32
+ }
33
+ }
34
+
19
35
  let context: BrowserContext | null = null;
20
36
  export let activePage: Page | null = null;
21
37
  const accountContexts = new Map<string, BrowserContext>();
@@ -29,6 +45,7 @@ interface AccountHeaderCache {
29
45
  }
30
46
 
31
47
  const accountHeaderCaches = new Map<string, AccountHeaderCache>();
48
+ const cachedUserAgents = new Map<string, string>();
32
49
 
33
50
  function getAccountHeaderCache(accountId: string): AccountHeaderCache {
34
51
  let cache = accountHeaderCaches.get(accountId);
@@ -119,9 +136,14 @@ export async function getBasicHeaders(accountId?: string): Promise<{ cookie: str
119
136
  if (!page) throw new Error('Playwright not initialized');
120
137
 
121
138
  const cookie = await getCookies(accountId);
122
- const userAgent = await page.evaluate(() => navigator.userAgent);
123
-
124
139
  const cacheKey = accountId || 'global';
140
+
141
+ let userAgent = cachedUserAgents.get(cacheKey);
142
+ if (!userAgent) {
143
+ userAgent = await page.evaluate(() => navigator.userAgent);
144
+ cachedUserAgents.set(cacheKey, userAgent);
145
+ }
146
+
125
147
  const cache = getAccountHeaderCache(cacheKey);
126
148
  const bxV = cache.currentHeaders['bx-v'] || '2.5.36';
127
149
  const bxUa = cache.currentHeaders['bx-ua'];
@@ -137,34 +159,11 @@ export async function initPlaywright(headless = true, browserType: BrowserType =
137
159
  }
138
160
 
139
161
  const profilePath = path.resolve('qwen_profiles', '_default');
140
-
141
- let browserEngine;
142
- let channel: string | undefined;
143
-
144
- switch (browserType) {
145
- case 'firefox':
146
- browserEngine = firefox;
147
- break;
148
- case 'webkit':
149
- browserEngine = webkit;
150
- break;
151
- case 'chrome':
152
- browserEngine = chromium;
153
- channel = 'chrome';
154
- break;
155
- case 'edge':
156
- browserEngine = chromium;
157
- channel = 'msedge';
158
- break;
159
- case 'chromium':
160
- default:
161
- browserEngine = chromium;
162
- break;
163
- }
162
+ const { engine, channel } = resolveBrowserEngine(browserType);
164
163
 
165
164
  console.log(`[Playwright] Launching ${browserType}...`);
166
165
 
167
- context = await browserEngine.launchPersistentContext(profilePath, {
166
+ context = await engine.launchPersistentContext(profilePath, {
168
167
  headless,
169
168
  channel,
170
169
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
@@ -248,45 +247,8 @@ export async function closePlaywright() {
248
247
 
249
248
  export async function loginToQwen(email: string, password: string): Promise<boolean> {
250
249
  if (!activePage) throw new Error('Playwright not initialized');
251
-
252
250
  console.log(`[Playwright] Attempting API login for ${email}...`);
253
-
254
- await activePage.goto('https://chat.qwen.ai/auth', { waitUntil: 'domcontentloaded' });
255
-
256
- const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
257
-
258
- const result = await activePage.evaluate(async ({ email, password }) => {
259
- try {
260
- const response = await fetch("https://chat.qwen.ai/api/v2/auths/signin", {
261
- method: "POST",
262
- headers: {
263
- "accept": "application/json, text/plain, */*",
264
- "content-type": "application/json",
265
- "source": "web",
266
- "timezone": new Date().toString().split(' (')[0],
267
- "x-request-id": crypto.randomUUID()
268
- },
269
- body: JSON.stringify({ email, password, login_type: "email" })
270
- });
271
- const data = await response.json();
272
- return { ok: response.ok, data };
273
- } catch (e: any) {
274
- return { ok: false, error: e.message };
275
- }
276
- }, { email, password: hashedPassword });
277
-
278
- if (result.ok) {
279
- console.log('[Playwright] API login request successful.');
280
- await activePage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded' });
281
- const isLogged = !(activePage.url().includes('auth') || activePage.url().includes('login'));
282
- if (isLogged) {
283
- console.log('[Playwright] Login confirmed.');
284
- return true;
285
- }
286
- }
287
-
288
- console.error('[Playwright] Login failed:', result.data || result.error);
289
- return false;
251
+ return loginToQwenWithContext(activePage.context(), activePage, email, password);
290
252
  }
291
253
 
292
254
  async function loginToQwenUI(email: string, password: string): Promise<boolean> {
@@ -373,7 +335,11 @@ async function tryLightweightCookieRefresh(accountId?: string): Promise<{ header
373
335
  try {
374
336
  const cookies = await page.context().cookies();
375
337
  const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
376
- const userAgent = await page.evaluate(() => navigator.userAgent);
338
+ let userAgent = cachedUserAgents.get(cacheKey);
339
+ if (!userAgent) {
340
+ userAgent = await page.evaluate(() => navigator.userAgent);
341
+ cachedUserAgents.set(cacheKey, userAgent);
342
+ }
377
343
 
378
344
  const now = Date.now();
379
345
  cookieCaches.set(cacheKey, { cookie: cookieStr, timestamp: now });
@@ -472,7 +438,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
472
438
  console.warn('[Playwright] Detected login page but QWEN_EMAIL/PASSWORD not provided in .env');
473
439
  }
474
440
  } else {
475
- const { getAccountCredentials } = await import('../core/accounts.ts');
441
+ const { getAccountCredentials } = await import('../core/accounts.js');
476
442
  const creds = getAccountCredentials(accountId);
477
443
  if (creds && creds.email && creds.password) {
478
444
  console.log(`[Playwright] Detected login page for account ${creds.email}. Attempting login...`);
@@ -547,7 +513,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
547
513
  cache.lastHeadersTime = Date.now();
548
514
  cache.refreshInProgress = false;
549
515
 
550
- import('./qwen.ts').then(m => m.disableNativeTools(accountId).catch(() => {}));
516
+ import('./qwen.js').then(m => m.disableNativeTools(accountId).catch(() => {}));
551
517
 
552
518
  await route.abort('aborted');
553
519
 
@@ -608,34 +574,11 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
608
574
 
609
575
  export async function initPlaywrightForAccount(account: QwenAccount, headless = true, browserType: BrowserType = 'chromium') {
610
576
  const profilePath = path.resolve('qwen_profiles', account.id);
611
-
612
- let browserEngine;
613
- let channel: string | undefined;
614
-
615
- switch (browserType) {
616
- case 'firefox':
617
- browserEngine = firefox;
618
- break;
619
- case 'webkit':
620
- browserEngine = webkit;
621
- break;
622
- case 'chrome':
623
- browserEngine = chromium;
624
- channel = 'chrome';
625
- break;
626
- case 'edge':
627
- browserEngine = chromium;
628
- channel = 'msedge';
629
- break;
630
- case 'chromium':
631
- default:
632
- browserEngine = chromium;
633
- break;
634
- }
577
+ const { engine, channel } = resolveBrowserEngine(browserType);
635
578
 
636
579
  console.log(`[Playwright] Launching ${browserType} for account ${account.email}...`);
637
580
 
638
- const acctContext = await browserEngine.launchPersistentContext(profilePath, {
581
+ const acctContext = await engine.launchPersistentContext(profilePath, {
639
582
  headless,
640
583
  channel,
641
584
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
@@ -665,32 +608,9 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
665
608
 
666
609
  export async function launchManualLoginAccount(accountId: string, browserType: BrowserType = 'chromium'): Promise<{ context: BrowserContext, page: Page }> {
667
610
  const profilePath = path.resolve('qwen_profiles', accountId);
668
-
669
- let browserEngine;
670
- let channel: string | undefined;
671
-
672
- switch (browserType) {
673
- case 'firefox':
674
- browserEngine = firefox;
675
- break;
676
- case 'webkit':
677
- browserEngine = webkit;
678
- break;
679
- case 'chrome':
680
- browserEngine = chromium;
681
- channel = 'chrome';
682
- break;
683
- case 'edge':
684
- browserEngine = chromium;
685
- channel = 'msedge';
686
- break;
687
- case 'chromium':
688
- default:
689
- browserEngine = chromium;
690
- break;
691
- }
611
+ const { engine, channel } = resolveBrowserEngine(browserType);
692
612
 
693
- const acctContext = await browserEngine.launchPersistentContext(profilePath, {
613
+ const acctContext = await engine.launchPersistentContext(profilePath, {
694
614
  headless: false,
695
615
  channel,
696
616
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
@@ -1,5 +1,7 @@
1
- import { getQwenHeaders, getBasicHeaders } from './playwright.ts';
2
- import { v4 as uuidv4 } from 'uuid';
1
+ import { getQwenHeaders, getBasicHeaders } from './playwright.js';
2
+ import crypto from 'crypto';
3
+
4
+ const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
3
5
 
4
6
  export class RetryableQwenStreamError extends Error {
5
7
  readonly retryAfterMs: number;
@@ -26,8 +28,7 @@ interface SessionEntry {
26
28
  timestamp: number;
27
29
  }
28
30
 
29
- const sessionStates: Map<string, SessionEntry> = (globalThis as any)._sessionStates || new Map();
30
- (globalThis as any)._sessionStates = sessionStates;
31
+ const sessionStates: Map<string, SessionEntry> = new Map();
31
32
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
32
33
 
33
34
  function cleanupStaleSessions() {
@@ -63,11 +64,9 @@ interface WarmPoolEntry {
63
64
  timestamp: number;
64
65
  }
65
66
 
66
- const warmPool: Map<string, WarmPoolEntry[]> = (globalThis as any)._warmPool || new Map();
67
- (globalThis as any)._warmPool = warmPool;
67
+ const warmPool: Map<string, WarmPoolEntry[]> = new Map();
68
68
 
69
- const refillPromises: Map<string, Promise<void>> = (globalThis as any)._refillPromises || new Map();
70
- (globalThis as any)._refillPromises = refillPromises;
69
+ const refillPromises: Map<string, Promise<void>> = new Map();
71
70
 
72
71
  const WARM_POOL_SIZE = 5;
73
72
  const WARM_POOL_TTL_MS = 10 * 60 * 1000;
@@ -76,13 +75,11 @@ function cleanupStalePool(accountId: string) {
76
75
  const pool = warmPool.get(accountId);
77
76
  if (!pool) return;
78
77
  const now = Date.now();
79
- for (let i = pool.length - 1; i >= 0; i--) {
80
- if (now - pool[i].timestamp > WARM_POOL_TTL_MS) pool.splice(i, 1);
81
- }
78
+ const filtered = pool.filter(e => now - e.timestamp <= WARM_POOL_TTL_MS);
79
+ if (filtered.length !== pool.length) warmPool.set(accountId, filtered);
82
80
  }
83
81
 
84
82
  async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
85
- const { getBasicHeaders } = await import('./playwright.ts');
86
83
  const { cookie, userAgent, bxV } = await getBasicHeaders(accountId);
87
84
  return {
88
85
  cookie,
@@ -92,8 +89,6 @@ async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, s
92
89
  }
93
90
 
94
91
  async function createRealQwenChat(header: Record<string, string>): Promise<string> {
95
- const controller = new AbortController();
96
- const timeoutId = setTimeout(() => controller.abort(), 30000);
97
92
  const response = await fetch('https://chat.qwen.ai/api/v2/chats/new', {
98
93
  method: 'POST',
99
94
  headers: {
@@ -104,7 +99,7 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
104
99
  origin: 'https://chat.qwen.ai',
105
100
  referer: 'https://chat.qwen.ai/c/new-chat',
106
101
  'user-agent': header['user-agent'],
107
- 'x-request-id': uuidv4(),
102
+ 'x-request-id': crypto.randomUUID(),
108
103
  'bx-v': header['bx-v'],
109
104
  },
110
105
  body: JSON.stringify({
@@ -115,9 +110,8 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
115
110
  timestamp: Date.now(),
116
111
  project_id: '',
117
112
  }),
118
- signal: controller.signal,
113
+ signal: AbortSignal.timeout(30000),
119
114
  });
120
- clearTimeout(timeoutId);
121
115
 
122
116
  if (!response.ok) throw new Error(`Failed to create chat: ${response.status}`);
123
117
  const json = await response.json();
@@ -131,18 +125,26 @@ async function refillPoolForAccount(accountId: string) {
131
125
  if (!pool) { pool = []; warmPool.set(accountId, pool); }
132
126
  cleanupStalePool(accountId);
133
127
  const need = Math.max(0, WARM_POOL_SIZE - pool.length);
134
-
128
+ if (need === 0) return;
129
+
130
+ let headers: Record<string, string>;
131
+ try {
132
+ headers = await getBasicQwenHeaders(accountId === 'global' ? undefined : accountId);
133
+ } catch (err) {
134
+ console.error(`[WarmPool] header fetch failed for ${accountId}:`, (err as Error).message);
135
+ return;
136
+ }
137
+
135
138
  const creationPromises = Array.from({ length: need }, async () => {
136
139
  try {
137
- const headers = await getBasicQwenHeaders(accountId === 'global' ? undefined : accountId);
138
140
  const chatId = await createRealQwenChat(headers);
139
141
  return { chatId, headers, accountId, timestamp: Date.now() };
140
142
  } catch (err) {
141
- console.error(`[WarmPool] refill failed for ${accountId}:`, (err as Error).message);
143
+ console.error(`[WarmPool] chat creation failed for ${accountId}:`, (err as Error).message);
142
144
  return null;
143
145
  }
144
146
  });
145
-
147
+
146
148
  const results = await Promise.all(creationPromises);
147
149
  for (const entry of results) {
148
150
  if (entry) pool.push(entry);
@@ -252,7 +254,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
252
254
  'origin': 'https://chat.qwen.ai',
253
255
  'referer': 'https://chat.qwen.ai/',
254
256
  'user-agent': headers['user-agent'],
255
- 'x-request-id': uuidv4(),
257
+ 'x-request-id': crypto.randomUUID(),
256
258
  'bx-ua': headers['bx-ua'],
257
259
  'bx-umidtoken': headers['bx-umidtoken'],
258
260
  'bx-v': headers['bx-v']
@@ -291,9 +293,9 @@ export async function fetchQwenModels(accountId?: string): Promise<any[]> {
291
293
  'cookie': cookie,
292
294
  'referer': 'https://chat.qwen.ai/',
293
295
  'user-agent': userAgent,
294
- 'x-request-id': uuidv4(),
296
+ 'x-request-id': crypto.randomUUID(),
295
297
  'bx-v': bxV,
296
- 'timezone': new Date().toString(),
298
+ 'timezone': CACHED_TIMEZONE,
297
299
  'source': 'web'
298
300
  }
299
301
  });
@@ -397,7 +399,7 @@ export async function createQwenStream(
397
399
  }
398
400
 
399
401
  const timestamp = Math.floor(Date.now() / 1000);
400
- const fid = uuidv4();
402
+ const fid = crypto.randomUUID();
401
403
  const model = modelId.replace('-no-thinking', '');
402
404
 
403
405
  const payload: QwenPayload = {
@@ -456,10 +458,10 @@ export async function createQwenStream(
456
458
  'sec-fetch-dest': 'empty',
457
459
  'sec-fetch-mode': 'cors',
458
460
  'sec-fetch-site': 'same-origin',
459
- 'timezone': new Date().toString().split(' (')[0],
461
+ 'timezone': CACHED_TIMEZONE,
460
462
  'user-agent': chatHeaders['user-agent'],
461
463
  'x-accel-buffering': 'no',
462
- 'x-request-id': uuidv4(),
464
+ 'x-request-id': crypto.randomUUID(),
463
465
  'bx-v': chatHeaders['bx-v'],
464
466
  },
465
467
  body: JSON.stringify(payload),
@@ -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
+ });