w-dispatch-ai 1.0.11 → 1.0.13

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 (51) hide show
  1. package/README.md +33 -5
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +15 -3
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/budgetFor.mjs.html +124 -0
  7. package/docs/castPintOr.mjs.html +2 -2
  8. package/docs/dfTimeoutMs.mjs.html +2 -2
  9. package/docs/dispatchAi.mjs.html +2 -2
  10. package/docs/dispatchAiFallback.mjs.html +2 -2
  11. package/docs/dispatchAiWkf.mjs.html +2 -2
  12. package/docs/dispatchAntigravity.mjs.html +2 -2
  13. package/docs/dispatchApiOpenaiCompat.mjs.html +2 -2
  14. package/docs/dispatchClaude.mjs.html +2 -2
  15. package/docs/dispatchCodex.mjs.html +2 -2
  16. package/docs/dispatchOpencode.mjs.html +2 -2
  17. package/docs/getCliArgs.mjs.html +2 -2
  18. package/docs/getErrorResult.mjs.html +2 -2
  19. package/docs/getErrorType.mjs.html +2 -2
  20. package/docs/global.html +1632 -49
  21. package/docs/index.html +2 -2
  22. package/docs/readEnvFile.mjs.html +132 -0
  23. package/docs/resolveProviders.mjs.html +54 -6
  24. package/docs/wkf_callAiWithFallback.mjs.html +7 -22
  25. package/docs/wkf_createFileStore.mjs.html +175 -0
  26. package/docs/wkf_createUsageCounter.mjs.html +213 -0
  27. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  28. package/docs/wkf_noSideEffectPrefix.mjs.html +121 -0
  29. package/docs/wkf_runFanout.mjs.html +2 -2
  30. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  31. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  32. package/docs/wkf_salvageTruncatedArray.mjs.html +167 -0
  33. package/package.json +2 -2
  34. package/src/WDispatchAi.mjs +13 -1
  35. package/src/budgetFor.mjs +52 -0
  36. package/src/providers.mjs +4 -3
  37. package/src/readEnvFile.mjs +60 -0
  38. package/src/resolveProviders.mjs +52 -4
  39. package/src/wkf/callAiWithFallback.mjs +5 -20
  40. package/src/wkf/createFileStore.mjs +103 -0
  41. package/src/wkf/createUsageCounter.mjs +141 -0
  42. package/src/wkf/noSideEffectPrefix.mjs +49 -0
  43. package/src/wkf/salvageTruncatedArray.mjs +95 -0
  44. package/test/unit-WDispatchAi.test.mjs +9 -3
  45. package/test/unit-budgetFor.test.mjs +26 -0
  46. package/test/unit-callAiWithFallback.test.mjs +8 -0
  47. package/test/unit-createFileStore.test.mjs +66 -0
  48. package/test/unit-createUsageCounter.test.mjs +76 -0
  49. package/test/unit-readEnvFile.test.mjs +52 -0
  50. package/test/unit-resolveProviders.test.mjs +49 -0
  51. package/test/unit-salvageTruncatedArray.test.mjs +39 -0
@@ -0,0 +1,95 @@
1
+ // salvageTruncatedArray.mjs — 截斷JSON陣列之前段搶救
2
+ //
3
+ // 【為何需要】免費模型常在長陣列輸出中途被截斷(無閉合括號), 整包JSON.parse必失敗,
4
+ // 但前面k個元素是完整的。使用端殷鑑(2026-08-07~08): 兩日22次OUTPUT_VALIDATION_FAILED,
5
+ // preview首元素皆完整合法——整批成果被截斷尾巴拖垮, 其中一輪3/3批全滅、整輪0產出。
6
+ // 字元掃描(含字串與跳脫感知)找出「最後一個完整的頂層元素」, 補上`]`後重新解析——
7
+ // 搶救回來的是**完整合法**的前段元素, 不是半成品。
8
+ //
9
+ // 【定位: 獨立工具, 不併入extractJsonLoose預設行為】「截斷」有兩種同樣合法的策略:
10
+ // ①判失敗換家重產(全涵蓋驗證, 如逐篇摘要——缺篇的輸出本身即不完整);
11
+ // ②搶救前段部分接受(批次萃取——避免全有全無, 一批30項救回25項遠勝重跑)。
12
+ // 套件提供工具, 策略由呼叫端選: extractJsonLoose檔頭明示之opt.parse擴充點即為此而留。
13
+ //
14
+ // 【只搶救「掃到結尾仍未閉合」的截斷】陣列有正常閉合但parse失敗屬別種毛病
15
+ // (非法字元/壞跳脫), 補`]`救不了反而可能拼出語意錯誤的結果, 一律不碰回null。
16
+
17
+
18
+ /**
19
+ * 自截斷的JSON陣列文字搶救出前段完整元素(頂層元素須為物件)
20
+ *
21
+ * 特點:
22
+ * 自第一個`[`起逐字元掃描(字串與跳脫感知),追蹤「最後一個完整結束的頂層物件元素」,
23
+ * 補上`]`重新解析——回傳的每個元素皆完整合法,不是半成品;
24
+ * 陣列有正常閉合(=非截斷)、無`[`、或救回後為空陣列,一律回null不硬救;
25
+ * 典型用法:extractJsonLoose回null時之後備,或組成自訂parse注入callAiWithFallback
26
+ *
27
+ * @param {String} text 輸入模型輸出文字字串
28
+ * @returns {Array|null} 回傳搶救出之非空陣列,無法搶救回null
29
+ * @example
30
+ *
31
+ * import salvageTruncatedArray from './src/wkf/salvageTruncatedArray.mjs'
32
+ *
33
+ * console.log(salvageTruncatedArray('[{"a":1},{"b":2},{"c":'))
34
+ * // => [ { a: 1 }, { b: 2 } ]
35
+ *
36
+ * console.log(salvageTruncatedArray('[{"a":1}]')) //有閉合=非截斷, 不在搶救範圍
37
+ * // => null
38
+ *
39
+ */
40
+ function salvageTruncatedArray(text) {
41
+ let s = String(text || '')
42
+ let start = s.indexOf('[')
43
+ if (start < 0) {
44
+ return null
45
+ }
46
+ let depth = 0
47
+ let inStr = false
48
+ let esc = false
49
+ let lastComplete = -1
50
+ for (let i = start; i < s.length; i++) {
51
+ let ch = s[i]
52
+ if (esc) {
53
+ esc = false
54
+ continue
55
+ }
56
+ if (inStr) {
57
+ if (ch === '\\') {
58
+ esc = true
59
+ }
60
+ else if (ch === '"') {
61
+ inStr = false
62
+ }
63
+ continue
64
+ }
65
+ if (ch === '"') {
66
+ inStr = true
67
+ continue
68
+ }
69
+ if (ch === '[' || ch === '{') {
70
+ depth++
71
+ }
72
+ else if (ch === ']' || ch === '}') {
73
+ depth--
74
+ if (depth === 1 && ch === '}') {
75
+ lastComplete = i //頂層元素(物件)完整結束
76
+ }
77
+ if (depth === 0) {
78
+ return null //陣列有閉合=非截斷, 不在搶救範圍
79
+ }
80
+ }
81
+ }
82
+ if (lastComplete < 0) {
83
+ return null
84
+ }
85
+ try {
86
+ let r = JSON.parse(s.slice(start, lastComplete + 1) + ']')
87
+ return (Array.isArray(r) && r.length > 0) ? r : null
88
+ }
89
+ catch (e) {
90
+ return null
91
+ }
92
+ }
93
+
94
+
95
+ export default salvageTruncatedArray
@@ -16,7 +16,7 @@ import resolveProviders from '../src/resolveProviders.mjs'
16
16
 
17
17
  describe('WDispatchAi', function() {
18
18
 
19
- it('對外提供KINDS與各dispatch函數', function() {
19
+ it('對外提供KINDS與各dispatch函數與工具', function() {
20
20
  let r = keys(wi)
21
21
  let rr = [
22
22
  'KINDS',
@@ -30,6 +30,12 @@ describe('WDispatchAi', function() {
30
30
  'dispatchApiOpenaiCompat',
31
31
  'providers',
32
32
  'resolveProviders',
33
+ 'readEnvFile',
34
+ 'budgetFor',
35
+ 'createFileStore',
36
+ 'createUsageCounter',
37
+ 'salvageTruncatedArray',
38
+ 'NO_SIDE_EFFECT',
33
39
  ]
34
40
  assert.strict.deepEqual(r, rr)
35
41
  })
@@ -40,9 +46,9 @@ describe('WDispatchAi', function() {
40
46
  assert.strict.deepEqual(r, rr)
41
47
  })
42
48
 
43
- it('各鍵值型別正確(KINDS與providers為物件, 其餘為函數)', function() {
49
+ it('各鍵值型別正確(KINDS與providers為物件, NO_SIDE_EFFECT為字串, 其餘為函數)', function() {
44
50
  let r = map(keys(wi), (k) => typeof wi[k])
45
- let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function']
51
+ let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function', 'function', 'function', 'function', 'function', 'function', 'string']
46
52
  assert.strict.deepEqual(r, rr)
47
53
  })
48
54
 
@@ -0,0 +1,26 @@
1
+ import assert from 'assert'
2
+ import budgetFor from '../src/budgetFor.mjs'
3
+ import dfTimeoutMs from '../src/dfTimeoutMs.mjs'
4
+
5
+
6
+ describe('budgetFor', function() {
7
+
8
+ it('遞補鏈時間預算為各條目timeoutMs之總和', function() {
9
+ let r = budgetFor([{ timeoutMs: 180000 }, { timeoutMs: 240000 }])
10
+ let rr = 420000
11
+ assert.strict.deepEqual(r, rr)
12
+ })
13
+
14
+ it('未帶或無效timeoutMs之條目以套件統一預設計(與執行時實際取值一致)', function() {
15
+ let r = budgetFor([{ timeoutMs: 180000 }, {}, { timeoutMs: 'abc' }, { timeoutMs: -1 }])
16
+ let rr = 180000 + dfTimeoutMs * 3
17
+ assert.strict.deepEqual(r, rr)
18
+ })
19
+
20
+ it('輸入非陣列回傳0', function() {
21
+ let r = [budgetFor(null), budgetFor(undefined), budgetFor('abc'), budgetFor({})]
22
+ let rr = [0, 0, 0, 0]
23
+ assert.strict.deepEqual(r, rr)
24
+ })
25
+
26
+ })
@@ -1,5 +1,6 @@
1
1
  import assert from 'assert'
2
2
  import callAiWithFallback, { buildChain, NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
3
+ import noSideEffectPrefix from '../src/wkf/noSideEffectPrefix.mjs'
3
4
  import createFakeCli from './tools/fakeCliForTest.mjs'
4
5
 
5
6
 
@@ -25,6 +26,13 @@ describe('callAiWithFallback', function() {
25
26
  }
26
27
  })
27
28
 
29
+ it('NO_SIDE_EFFECT為單一來源: 本層再匯出與noSideEffectPrefix模組為同一份', function() {
30
+ //措辭曾分岔於消費端(套件修了codex唯讀豁免、消費端還帶舊措辭), 抽檔後鎖定同源
31
+ let r = [NO_SIDE_EFFECT === noSideEffectPrefix, NO_SIDE_EFFECT.includes('唯讀查閱')]
32
+ let rr = [true, true]
33
+ assert.strict.deepEqual(r, rr)
34
+ })
35
+
28
36
  it('buildChain以名稱展開條目並回報查無定義之名稱', function() {
29
37
  let { chain, missing } = buildChain(providers, { use: 'p-claude', fallback: ['p-codex', 'p-typo'] })
30
38
  let r = [chain.map((x) => x.id), missing]
@@ -0,0 +1,66 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import createFileStore from '../src/wkf/createFileStore.mjs'
6
+
7
+
8
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
9
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'file-store')
10
+
11
+
12
+ describe('createFileStore', function() {
13
+
14
+ before(function() {
15
+ fs.mkdirSync(FD, { recursive: true })
16
+ })
17
+
18
+ after(function() {
19
+ fs.rmSync(FD, { recursive: true, force: true })
20
+ try {
21
+ fs.rmdirSync(path.dirname(FD))
22
+ }
23
+ catch (e) {}
24
+ })
25
+
26
+ it('file與dir皆缺時throw(組裝期設定錯誤fail fast)', function() {
27
+ assert.throws(() => createFileStore(), { message: 'createFileStore: file or dir is required' })
28
+ })
29
+
30
+ it('dir+name組路徑, 檔案不存在時get回空物件(套件視為全新狀態)', function() {
31
+ let store = createFileStore({ dir: FD, name: 'fresh.json' })
32
+ let r = [store.file, store.get()]
33
+ let rr = [path.join(FD, 'fresh.json'), {}]
34
+ assert.strict.deepEqual(r, rr)
35
+ })
36
+
37
+ it('排除式passthrough: 未知欄位原封存還, 僅剔除自用欄位at(白名單丟棄cooling殷鑑)', function() {
38
+ let store = createFileStore({ dir: FD, name: 'rt.json', stamp: () => 'T0' })
39
+ //state含cursors/cooling與「套件未來新增的未知欄位」zz, 全數應原封回來
40
+ store.set({ cursors: { a: 1 }, cooling: { b: 1234 }, zz: { future: true } })
41
+ let onDisk = JSON.parse(fs.readFileSync(store.file, 'utf8'))
42
+ let r = [store.get(), onDisk.at]
43
+ let rr = [{ cursors: { a: 1 }, cooling: { b: 1234 }, zz: { future: true } }, 'T0']
44
+ assert.strict.deepEqual(r, rr)
45
+ })
46
+
47
+ it('可直接作dispatchAiFallback之store(get/set契約), 跨store實例讀同一檔', function() {
48
+ let fp = path.join(FD, 'shared.json')
49
+ let s1 = createFileStore({ file: fp })
50
+ s1.set({ cursors: { 'g-a': 2 } })
51
+ let s2 = createFileStore({ file: fp }) //新實例(模擬下一次行程)
52
+ let r = s2.get()
53
+ let rr = { cursors: { 'g-a': 2 } }
54
+ assert.strict.deepEqual(r, rr)
55
+ })
56
+
57
+ it('檔案內容非法JSON時get回空物件不throw', function() {
58
+ let fp = path.join(FD, 'broken.json')
59
+ fs.writeFileSync(fp, '{ not valid json', 'utf8')
60
+ let store = createFileStore({ file: fp })
61
+ let r = store.get()
62
+ let rr = {}
63
+ assert.strict.deepEqual(r, rr)
64
+ })
65
+
66
+ })
@@ -0,0 +1,76 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import createUsageCounter from '../src/wkf/createUsageCounter.mjs'
6
+
7
+
8
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
9
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'usage-counter')
10
+
11
+
12
+ describe('createUsageCounter', function() {
13
+
14
+ before(function() {
15
+ fs.mkdirSync(FD, { recursive: true })
16
+ })
17
+
18
+ after(function() {
19
+ fs.rmSync(FD, { recursive: true, force: true })
20
+ try {
21
+ fs.rmdirSync(path.dirname(FD))
22
+ }
23
+ catch (e) {}
24
+ })
25
+
26
+ it('file與dir皆缺時throw(組裝期設定錯誤fail fast)', function() {
27
+ assert.throws(() => createUsageCounter(), { message: 'createUsageCounter: file or dir is required' })
28
+ })
29
+
30
+ it('onEvent只於try事件記帳, 依keyOf(預設keyId)分鍵累加', function() {
31
+ let usage = createUsageCounter({ dir: FD, name: 'u1.json', getDate: () => '2026-08-18' })
32
+ usage.onEvent({ type: 'try', providerId: 'agnes:agnes-2.5-flash', keyIndex: 0, keyId: 'agnes:agnes-2.5-flash#0' })
33
+ usage.onEvent({ type: 'try', providerId: 'agnes:agnes-2.5-flash', keyIndex: 0, keyId: 'agnes:agnes-2.5-flash#0' })
34
+ usage.onEvent({ type: 'try', providerId: 'claude:sonnet', keyIndex: null, keyId: 'claude:sonnet' })
35
+ usage.onEvent({ type: 'ok', providerId: 'claude:sonnet', keyIndex: null, keyId: 'claude:sonnet' }) //非try不記
36
+ usage.onEvent({ type: 'next-key', providerId: 'x', keyId: 'x#0' }) //非try不記
37
+ let r = usage.today()
38
+ let rr = { today: '2026-08-18', byKey: { 'agnes:agnes-2.5-flash#0': 2, 'claude:sonnet': 1 }, total: 3 }
39
+ assert.strict.deepEqual(r, rr)
40
+ })
41
+
42
+ it('keyOf可改計帳粒度(記到條目), bump可手動累加', function() {
43
+ let usage = createUsageCounter({ dir: FD, name: 'u2.json', getDate: () => '2026-08-18', keyOf: (ev) => ev.providerId })
44
+ usage.onEvent({ type: 'try', providerId: 'agnes:agnes-2.5-flash', keyId: 'agnes:agnes-2.5-flash#0' })
45
+ usage.onEvent({ type: 'try', providerId: 'agnes:agnes-2.5-flash', keyId: 'agnes:agnes-2.5-flash#1' }) //不同金鑰同條目
46
+ usage.bump('manual-key', 5)
47
+ let r = usage.today()
48
+ let rr = { today: '2026-08-18', byKey: { 'agnes:agnes-2.5-flash': 2, 'manual-key': 5 }, total: 7 }
49
+ assert.strict.deepEqual(r, rr)
50
+ })
51
+
52
+ it('逐日分桶且只保留最近keepDays天', function() {
53
+ let day = '2026-08-01'
54
+ let usage = createUsageCounter({ dir: FD, name: 'u3.json', getDate: () => day, keepDays: 2 })
55
+ usage.bump('k')
56
+ day = '2026-08-02'
57
+ usage.bump('k')
58
+ day = '2026-08-03'
59
+ usage.bump('k')
60
+ let onDisk = JSON.parse(fs.readFileSync(usage.file, 'utf8'))
61
+ let r = [Object.keys(onDisk).sort(), usage.today()]
62
+ let rr = [['2026-08-02', '2026-08-03'], { today: '2026-08-03', byKey: { k: 1 }, total: 1 }] //08-01已被修剪
63
+ assert.strict.deepEqual(r, rr)
64
+ })
65
+
66
+ it('跨計數器實例讀同一檔(模擬下一次行程)', function() {
67
+ let usage1 = createUsageCounter({ dir: FD, name: 'u4.json', getDate: () => '2026-08-18' })
68
+ usage1.bump('k1')
69
+ let usage2 = createUsageCounter({ dir: FD, name: 'u4.json', getDate: () => '2026-08-18' })
70
+ usage2.bump('k1')
71
+ let r = usage2.today()
72
+ let rr = { today: '2026-08-18', byKey: { k1: 2 }, total: 2 }
73
+ assert.strict.deepEqual(r, rr)
74
+ })
75
+
76
+ })
@@ -0,0 +1,52 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import assert from 'assert'
5
+ import readEnvFile from '../src/readEnvFile.mjs'
6
+
7
+
8
+ //產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
9
+ let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'read-env-file')
10
+
11
+
12
+ describe('readEnvFile', function() {
13
+
14
+ before(function() {
15
+ fs.mkdirSync(FD, { recursive: true })
16
+ })
17
+
18
+ after(function() {
19
+ fs.rmSync(FD, { recursive: true, force: true })
20
+ try {
21
+ fs.rmdirSync(path.dirname(FD))
22
+ }
23
+ catch (e) {}
24
+ })
25
+
26
+ it('解析KEY=value, 剝除成對引號, 忽略空行註解與無效行, 且不寫入process.env', function() {
27
+ let fp = path.join(FD, 'a.env')
28
+ fs.writeFileSync(fp, [
29
+ '# comment',
30
+ 'AGNES_KEYS=sk-a,sk-b',
31
+ 'QUOTED_D="sk-dq"',
32
+ 'QUOTED_S=\'sk-sq\'',
33
+ 'SPACED = sk-sp ',
34
+ 'not a valid line',
35
+ '',
36
+ 'EMPTY_VAL=',
37
+ ].join('\n'), 'utf8')
38
+ let r = [readEnvFile(fp), process.env.AGNES_KEYS === undefined || process.env.AGNES_KEYS !== 'sk-a,sk-b']
39
+ let rr = [
40
+ { AGNES_KEYS: 'sk-a,sk-b', QUOTED_D: 'sk-dq', QUOTED_S: 'sk-sq', SPACED: 'sk-sp', EMPTY_VAL: '' },
41
+ true,
42
+ ]
43
+ assert.strict.deepEqual(r, rr)
44
+ })
45
+
46
+ it('檔案不存在回空物件不throw(金鑰缺失交由skipped機制回報)', function() {
47
+ let r = readEnvFile(path.join(FD, 'no-such.env'))
48
+ let rr = {}
49
+ assert.strict.deepEqual(r, rr)
50
+ })
51
+
52
+ })
@@ -82,4 +82,53 @@ describe('resolveProviders', function() {
82
82
  assert.strict.deepEqual(r, rr)
83
83
  })
84
84
 
85
+ it('exes逐kind注入exe, 條目已自帶exe者不覆寫(條目層優先)', function() {
86
+ let defs = [
87
+ { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' },
88
+ { id: 'claude:opus', kind: 'claude', model: 'opus', exe: 'D:/own/claude.exe' }, //自帶exe
89
+ { id: 'codex:g', kind: 'codex', model: 'g' }, //exes未含codex → 不動
90
+ ]
91
+ let t = resolveProviders(defs, { env: {}, exes: { claude: 'C:/bin/claude.exe' } })
92
+ let r = [
93
+ t.table['claude:sonnet'].exe,
94
+ t.table['claude:opus'].exe,
95
+ t.table['codex:g'].exe,
96
+ t.providers[0].exe, //陣列版與table版同源, 必然一致
97
+ ]
98
+ let rr = ['C:/bin/claude.exe', 'D:/own/claude.exe', undefined, 'C:/bin/claude.exe']
99
+ assert.strict.deepEqual(r, rr)
100
+ })
101
+
102
+ it('patch逐id淺合併覆寫, 於exes之後施作故可覆寫exe, 陣列與table同步生效', function() {
103
+ let defs = [
104
+ { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' },
105
+ { id: 'codex:g', kind: 'codex', model: 'g' },
106
+ ]
107
+ let t = resolveProviders(defs, {
108
+ env: {},
109
+ exes: { claude: 'C:/bin/claude.exe' },
110
+ patch: { 'claude:sonnet': { timeoutMs: 360000, exe: 'C:/patched/claude.exe' } },
111
+ })
112
+ let r = [
113
+ t.table['claude:sonnet'].timeoutMs,
114
+ t.table['claude:sonnet'].exe, //patch後於exes施作, 覆寫成功
115
+ t.providers[0].timeoutMs, //陣列版同步
116
+ t.table['codex:g'].timeoutMs, //未命中id者不動
117
+ t.providers[0] === t.table['claude:sonnet'], //同源同一物件
118
+ ]
119
+ let rr = [360000, 'C:/patched/claude.exe', 360000, undefined, true]
120
+ assert.strict.deepEqual(r, rr)
121
+ })
122
+
123
+ it('exes與patch皆省略時行為與原版完全相同(向後相容), 且不改動輸入條目', function() {
124
+ let defs = [{ id: 'a:m', kind: 'claude', model: 'm' }]
125
+ let before = JSON.stringify(defs)
126
+ let t1 = resolveProviders(defs, { env: {} })
127
+ let t2 = resolveProviders(defs, { env: {}, exes: { claude: 'C:/bin/claude.exe' }, patch: { 'a:m': { timeoutMs: 1 } } })
128
+ let r = [JSON.stringify(t1.table['a:m']), JSON.stringify(defs) === before, defs[0].exe, defs[0].timeoutMs]
129
+ let rr = ['{"id":"a:m","kind":"claude","model":"m"}', true, undefined, undefined]
130
+ assert.strict.deepEqual(r, rr)
131
+ assert.strict.deepEqual(t2.table['a:m'].timeoutMs, 1)
132
+ })
133
+
85
134
  })
@@ -0,0 +1,39 @@
1
+ import assert from 'assert'
2
+ import salvageTruncatedArray from '../src/wkf/salvageTruncatedArray.mjs'
3
+
4
+
5
+ describe('salvageTruncatedArray', function() {
6
+
7
+ it('截斷陣列搶救出前段完整元素(每個元素皆完整合法)', function() {
8
+ let r = [
9
+ salvageTruncatedArray('[{"a":1},{"b":2},{"c":'), //尾元素半途截斷
10
+ salvageTruncatedArray('[{"a":1},{"b":2}'), //恰於元素邊界截斷
11
+ salvageTruncatedArray('前導說明```json\n[{"a":1},{"b":{"c":2}},{"d"'), //含前導文字與巢狀物件
12
+ ]
13
+ let rr = [
14
+ [{ a: 1 }, { b: 2 }],
15
+ [{ a: 1 }, { b: 2 }],
16
+ [{ a: 1 }, { b: { c: 2 } }],
17
+ ]
18
+ assert.strict.deepEqual(r, rr)
19
+ })
20
+
21
+ it('字串與跳脫感知: 元素內含括號與跳脫引號不干擾配對', function() {
22
+ let r = salvageTruncatedArray('[{"a":"x]}","b":"y\\"z"},{"c":')
23
+ let rr = [{ a: 'x]}', b: 'y"z' }]
24
+ assert.strict.deepEqual(r, rr)
25
+ })
26
+
27
+ it('有正常閉合(非截斷)、無陣列、或救不回任何完整元素, 一律回null不硬救', function() {
28
+ let r = [
29
+ salvageTruncatedArray('[{"a":1}]'), //有閉合=非截斷
30
+ salvageTruncatedArray('純文字回覆'), //無[
31
+ salvageTruncatedArray('[{"a":'), //首元素即截斷, 無完整元素
32
+ salvageTruncatedArray(''),
33
+ salvageTruncatedArray(null),
34
+ ]
35
+ let rr = [null, null, null, null, null]
36
+ assert.strict.deepEqual(r, rr)
37
+ })
38
+
39
+ })