w-dispatch-ai 1.0.1 → 1.0.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 (46) hide show
  1. package/README.md +83 -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 +10 -4
  5. package/docs/adapters.mjs.html +10 -6
  6. package/docs/dispatchAi.mjs.html +2 -2
  7. package/docs/dispatchAiFallback.mjs.html +2 -2
  8. package/docs/dispatchAiWkf.mjs.html +172 -0
  9. package/docs/dispatchAntigravity.mjs.html +264 -0
  10. package/docs/dispatchApiOpenaiCompat.mjs.html +483 -0
  11. package/docs/dispatchClaude.mjs.html +2 -2
  12. package/docs/dispatchCodex.mjs.html +2 -2
  13. package/docs/dispatchOpencode.mjs.html +2 -2
  14. package/docs/getCliArgs.mjs.html +2 -2
  15. package/docs/getErrorResult.mjs.html +2 -2
  16. package/docs/global.html +6095 -1092
  17. package/docs/index.html +2 -2
  18. package/docs/wkf_callAiWithFallback.mjs.html +281 -0
  19. package/docs/wkf_extractJsonLoose.mjs.html +180 -0
  20. package/docs/wkf_runFanout.mjs.html +227 -0
  21. package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
  22. package/docs/wkf_runRolePipeline.mjs.html +195 -0
  23. package/g.mjs +19 -2
  24. package/package.json +1 -1
  25. package/src/WDispatchAi.mjs +8 -2
  26. package/src/adapters.mjs +8 -4
  27. package/src/dispatchAiWkf.mjs +100 -0
  28. package/src/dispatchAntigravity.mjs +192 -0
  29. package/src/dispatchApiOpenaiCompat.mjs +411 -0
  30. package/src/wkf/callAiWithFallback.mjs +209 -0
  31. package/src/wkf/extractJsonLoose.mjs +108 -0
  32. package/src/wkf/runFanout.mjs +155 -0
  33. package/src/wkf/runFanoutPipeline.mjs +106 -0
  34. package/src/wkf/runRolePipeline.mjs +123 -0
  35. package/test/tools/fakeServerForApiTest.mjs +137 -0
  36. package/test/unit-WDispatchAi.test.mjs +17 -6
  37. package/test/unit-adapters.test.mjs +7 -3
  38. package/test/unit-callAiWithFallback.test.mjs +146 -0
  39. package/test/unit-dispatchAi.test.mjs +7 -3
  40. package/test/unit-dispatchAiWkf.test.mjs +118 -0
  41. package/test/unit-dispatchAntigravity.test.mjs +173 -0
  42. package/test/unit-dispatchApiOpenaiCompat.test.mjs +233 -0
  43. package/test/unit-extractJsonLoose.test.mjs +78 -0
  44. package/test/unit-runFanout.test.mjs +166 -0
  45. package/test/unit-runFanoutPipeline.test.mjs +86 -0
  46. package/test/unit-runRolePipeline.test.mjs +163 -0
@@ -4,9 +4,12 @@ import map from 'lodash-es/map.js'
4
4
  import wi from '../src/WDispatchAi.mjs'
5
5
  import dispatchAi from '../src/dispatchAi.mjs'
6
6
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
7
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
7
8
  import dispatchOpencode from '../src/dispatchOpencode.mjs'
8
9
  import dispatchClaude from '../src/dispatchClaude.mjs'
9
10
  import dispatchCodex from '../src/dispatchCodex.mjs'
11
+ import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
12
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
10
13
 
11
14
 
12
15
  describe('WDispatchAi', function() {
@@ -17,22 +20,25 @@ describe('WDispatchAi', function() {
17
20
  'KINDS',
18
21
  'dispatchAi',
19
22
  'dispatchAiFallback',
23
+ 'dispatchAiWkf',
20
24
  'dispatchOpencode',
21
25
  'dispatchClaude',
22
26
  'dispatchCodex',
27
+ 'dispatchAntigravity',
28
+ 'dispatchApiOpenaiCompat',
23
29
  ]
24
30
  assert.strict.deepEqual(r, rr)
25
31
  })
26
32
 
27
33
  it('KINDS為可用之供應商種類字串陣列', function() {
28
34
  let r = wi.KINDS
29
- let rr = ['opencode', 'claude', 'codex']
35
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
30
36
  assert.strict.deepEqual(r, rr)
31
37
  })
32
38
 
33
39
  it('除KINDS外各鍵值皆為函數', function() {
34
40
  let r = map(keys(wi), (k) => typeof wi[k])
35
- let rr = ['object', 'function', 'function', 'function', 'function', 'function']
41
+ let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
36
42
  assert.strict.deepEqual(r, rr)
37
43
  })
38
44
 
@@ -40,11 +46,14 @@ describe('WDispatchAi', function() {
40
46
  let r = [
41
47
  wi.dispatchAi === dispatchAi,
42
48
  wi.dispatchAiFallback === dispatchAiFallback,
49
+ wi.dispatchAiWkf === dispatchAiWkf,
43
50
  wi.dispatchOpencode === dispatchOpencode,
44
51
  wi.dispatchClaude === dispatchClaude,
45
52
  wi.dispatchCodex === dispatchCodex,
53
+ wi.dispatchAntigravity === dispatchAntigravity,
54
+ wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
46
55
  ]
47
- let rr = [true, true, true, true, true]
56
+ let rr = [true, true, true, true, true, true, true, true]
48
57
  assert.strict.deepEqual(r, rr)
49
58
  })
50
59
 
@@ -54,20 +63,20 @@ describe('WDispatchAi', function() {
54
63
  let t = await wi.dispatchAi(kind, '')
55
64
  r.push(t.error)
56
65
  }
57
- let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
66
+ let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
58
67
  assert.strict.deepEqual(r, rr)
59
68
  })
60
69
 
61
70
  it('可由WDispatchAi呼叫dispatchAi', async function() {
62
71
  let t = await wi.dispatchAi('gemini', 'abc')
63
72
  let r = [t.ok, t.error]
64
- let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex)']
73
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)']
65
74
  assert.strict.deepEqual(r, rr)
66
75
  })
67
76
 
68
77
  it('可由WDispatchAi呼叫各轉接器', async function() {
69
78
  let r = []
70
- for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex]) {
79
+ for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat]) {
71
80
  let t = await fn('')
72
81
  r.push([t.ok, t.error])
73
82
  }
@@ -75,6 +84,8 @@ describe('WDispatchAi', function() {
75
84
  [false, 'prompt must be a non-empty string'],
76
85
  [false, 'prompt must be a non-empty string'],
77
86
  [false, 'prompt must be a non-empty string'],
87
+ [false, 'prompt must be a non-empty string'],
88
+ [false, 'prompt must be a non-empty string'],
78
89
  ]
79
90
  assert.strict.deepEqual(r, rr)
80
91
  })
@@ -5,19 +5,21 @@ import adapters from '../src/adapters.mjs'
5
5
  import dispatchOpencode from '../src/dispatchOpencode.mjs'
6
6
  import dispatchClaude from '../src/dispatchClaude.mjs'
7
7
  import dispatchCodex from '../src/dispatchCodex.mjs'
8
+ import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
9
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
8
10
 
9
11
 
10
12
  describe('adapters', function() {
11
13
 
12
14
  it('對照表鍵名即為可用之供應商種類', function() {
13
15
  let r = keys(adapters)
14
- let rr = ['opencode', 'claude', 'codex']
16
+ let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
15
17
  assert.strict.deepEqual(r, rr)
16
18
  })
17
19
 
18
20
  it('各鍵值皆為函數', function() {
19
21
  let r = map(keys(adapters), (k) => typeof adapters[k])
20
- let rr = ['function', 'function', 'function']
22
+ let rr = ['function', 'function', 'function', 'function', 'function']
21
23
  assert.strict.deepEqual(r, rr)
22
24
  })
23
25
 
@@ -26,8 +28,10 @@ describe('adapters', function() {
26
28
  adapters.opencode === dispatchOpencode,
27
29
  adapters.claude === dispatchClaude,
28
30
  adapters.codex === dispatchCodex,
31
+ adapters.antigravity === dispatchAntigravity,
32
+ adapters['api-openai-compat'] === dispatchApiOpenaiCompat,
29
33
  ]
30
- let rr = [true, true, true]
34
+ let rr = [true, true, true, true, true]
31
35
  assert.strict.deepEqual(r, rr)
32
36
  })
33
37
 
@@ -0,0 +1,146 @@
1
+ import assert from 'assert'
2
+ import callAiWithFallback, { buildChain, NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ describe('callAiWithFallback', function() {
7
+
8
+ let fake = null
9
+ let providers = null
10
+
11
+ before(function() {
12
+ fake = createFakeCli('fake-wkf-call')
13
+ providers = {
14
+ 'p-claude': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
15
+ 'p-codex': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
16
+ 'p-oc': { kind: 'opencode', exe: fake.exe, model: 'opencode/free', provider: 'opencode', keys: ['sk-c0', 'sk-c1'] },
17
+ 'p-dead': { kind: 'claude', exe: fake.exe, model: 'sonnet', extraArgs: ['--fake-exit=1'] },
18
+ 'p-noexe': { kind: 'claude', exe: 'wkf-no-such-cli' },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('buildChain以名稱展開條目並回報查無定義之名稱', function() {
29
+ let { chain, missing } = buildChain(providers, { use: 'p-claude', fallback: ['p-codex', 'p-typo'] })
30
+ let r = [chain.map((x) => x.id), missing]
31
+ let rr = [['p-claude', 'p-codex'], ['p-typo']]
32
+ assert.strict.deepEqual(r, rr)
33
+ })
34
+
35
+ it('prompt非有效字串時回傳錯誤結果物件', async function() {
36
+ let t = await callAiWithFallback('', { providers, spec: { use: 'p-claude' } })
37
+ let r = [t.ok, t.json, t.error, t.tried]
38
+ let rr = [false, null, 'prompt must be a non-empty string', []]
39
+ assert.strict.deepEqual(r, rr)
40
+ })
41
+
42
+ it('providers或spec無效時回傳錯誤結果物件', async function() {
43
+ let r = []
44
+ for (let [p, s] of [[null, { use: 'x' }], [providers, null], [providers, 'abc']]) {
45
+ let t = await callAiWithFallback('abc', { providers: p, spec: s })
46
+ r.push([t.ok, t.error.indexOf('no valid provider') === 0])
47
+ }
48
+ let rr = [[false, true], [false, true], [false, true]]
49
+ assert.strict.deepEqual(r, rr)
50
+ })
51
+
52
+ it('名稱查無定義時fail fast回報, 不靜默略過', async function() {
53
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude', fallback: ['p-typo1', 'p-codex', 'p-typo2'] } })
54
+ let r = [t.ok, t.error]
55
+ let rr = [false, 'unknown provider name(s): p-typo1, p-typo2']
56
+ assert.strict.deepEqual(r, rr)
57
+ })
58
+
59
+ it('成功時回傳解析後json與名稱式providerId', async function() {
60
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
61
+ let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.json.args, t.json.stdin]
62
+ let rr = [true, 'p-claude', null, 'p-claude', ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
63
+ assert.strict.deepEqual(r, rr)
64
+ })
65
+
66
+ it('預設掛防寫檔前綴, promptPrefix傳空字串可關閉, 自訂前綴可覆寫', async function() {
67
+ let t1 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' } })
68
+ let t2 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
69
+ let t3 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: 'PREFIX:' })
70
+ let r = [t1.json.stdin, t2.json.stdin, t3.json.stdin]
71
+ let rr = [NO_SIDE_EFFECT + 'abc', 'abc', 'PREFIX:abc']
72
+ assert.strict.deepEqual(r, rr)
73
+ })
74
+
75
+ it('check未過視為該家失敗, 遞補層自動換下一家', async function() {
76
+ //check要求args含opencode之run旗標 → p-claude不合(換家) → p-oc合
77
+ let t = await callAiWithFallback('abc', {
78
+ providers,
79
+ spec: { use: 'p-claude', fallback: ['p-oc'] },
80
+ check: (j) => j.args[0] === 'run',
81
+ promptPrefix: '',
82
+ })
83
+ let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.tried.map((x) => [x.providerId, x.outcome])]
84
+ let rr = [true, 'p-oc', 0, 'p-oc#0', [['p-claude', 'skip-group'], ['p-oc', 'ok']]]
85
+ assert.strict.deepEqual(r, rr)
86
+ })
87
+
88
+ it('CLI失敗(exit 1)時遞補至下一家', async function() {
89
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-codex'] }, promptPrefix: '' })
90
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome])]
91
+ let rr = [true, 'p-codex', [['p-dead', 'next-key'], ['p-codex', 'ok']]]
92
+ assert.strict.deepEqual(r, rr)
93
+ })
94
+
95
+ it('全數失敗時回傳error與完整tried', async function() {
96
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-noexe'] }, promptPrefix: '' })
97
+ let r = [t.ok, t.json, t.error !== '', t.tried.length]
98
+ let rr = [false, null, true, 2]
99
+ assert.strict.deepEqual(r, rr)
100
+ })
101
+
102
+ it('rawText模式不解析JSON, json欄為修剪後文字', async function() {
103
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, rawText: true, promptPrefix: '' })
104
+ let r = [t.ok, typeof t.json, JSON.parse(t.json).stdin]
105
+ let rr = [true, 'string', 'abc']
106
+ assert.strict.deepEqual(r, rr)
107
+ })
108
+
109
+ it('rawText模式check收文字', async function() {
110
+ let seen = []
111
+ let t = await callAiWithFallback('abc', {
112
+ providers,
113
+ spec: { use: 'p-claude' },
114
+ rawText: true,
115
+ check: (s) => {
116
+ seen.push(typeof s)
117
+ return s.includes('"stdin"')
118
+ },
119
+ promptPrefix: '',
120
+ })
121
+ let r = [t.ok, seen.includes('string')]
122
+ let rr = [true, true]
123
+ assert.strict.deepEqual(r, rr)
124
+ })
125
+
126
+ it('自訂parse可覆寫預設解析器', async function() {
127
+ let t = await callAiWithFallback('abc', {
128
+ providers,
129
+ spec: { use: 'p-claude' },
130
+ parse: (stdout) => ({ wrapped: JSON.parse(stdout).stdin }),
131
+ promptPrefix: '',
132
+ })
133
+ let r = t.json
134
+ let rr = { wrapped: 'abc' }
135
+ assert.strict.deepEqual(r, rr)
136
+ })
137
+
138
+ it('多金鑰條目經由keys輪替且keyId帶索引', async function() {
139
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-oc' }, promptPrefix: '' })
140
+ let auth = JSON.parse(t.json.env.OPENCODE_AUTH_CONTENT)
141
+ let r = [t.ok, t.providerId, typeof t.keyIndex, t.keyId.indexOf('p-oc#') === 0, ['sk-c0', 'sk-c1'].includes(auth.opencode.key)]
142
+ let rr = [true, 'p-oc', 'number', true, true]
143
+ assert.strict.deepEqual(r, rr)
144
+ })
145
+
146
+ })
@@ -20,7 +20,7 @@ describe('dispatchAi', function() {
20
20
  it('未知kind時回傳錯誤結果物件且不reject', async function() {
21
21
  let t = await dispatchAi('gemini', 'abc')
22
22
  let r = [t.ok, t.error, t.attempts]
23
- let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex)', 0]
23
+ let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)', 0]
24
24
  assert.strict.deepEqual(r, rr)
25
25
  })
26
26
 
@@ -46,7 +46,7 @@ describe('dispatchAi', function() {
46
46
 
47
47
  it('prompt之檢核由各轉接器負責, 錯誤結果物件一致', async function() {
48
48
  let r = []
49
- for (let kind of ['opencode', 'claude', 'codex']) {
49
+ for (let kind of ['opencode', 'claude', 'codex', 'antigravity']) {
50
50
  let t = await dispatchAi(kind, '')
51
51
  r.push([t.ok, t.error])
52
52
  }
@@ -54,13 +54,14 @@ describe('dispatchAi', function() {
54
54
  [false, 'prompt must be a non-empty string'],
55
55
  [false, 'prompt must be a non-empty string'],
56
56
  [false, 'prompt must be a non-empty string'],
57
+ [false, 'prompt must be a non-empty string'],
57
58
  ]
58
59
  assert.strict.deepEqual(r, rr)
59
60
  })
60
61
 
61
62
  it('各kind分派至對應轉接器, 由其固定旗標可辨識', async function() {
62
63
  let r = []
63
- for (let kind of ['opencode', 'claude', 'codex']) {
64
+ for (let kind of ['opencode', 'claude', 'codex', 'antigravity']) {
64
65
  let t = await dispatchAi(kind, 'abc', { exe: fake.exe, model: 'mdl' })
65
66
  let o = JSON.parse(t.stdout)
66
67
  r.push([t.ok, o.args])
@@ -69,6 +70,7 @@ describe('dispatchAi', function() {
69
70
  [true, ['run', '--agent', 'build', '-m', 'mdl']],
70
71
  [true, ['-p', '--dangerously-skip-permissions', '--model', 'mdl']],
71
72
  [true, ['exec', '--sandbox', 'workspace-write', '--skip-git-repo-check', '-m', 'mdl']],
73
+ [true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'mdl', '--print', 'abc']],
72
74
  ]
73
75
  assert.strict.deepEqual(r, rr)
74
76
  })
@@ -86,6 +88,7 @@ describe('dispatchAi', function() {
86
88
  { kind: 'opencode', model: 'opencode/free', provider: 'opencode', key: 'sk-1' },
87
89
  { kind: 'claude', model: 'sonnet' },
88
90
  { kind: 'codex', model: 'gpt-5.6-luna' },
91
+ { kind: 'antigravity', model: 'gemini-3.6-flash-low' },
89
92
  ]
90
93
  let r = []
91
94
  for (let item of items) {
@@ -97,6 +100,7 @@ describe('dispatchAi', function() {
97
100
  ['opencode', 'run', true],
98
101
  ['claude', '-p', true],
99
102
  ['codex', 'exec', true],
103
+ ['antigravity', '--dangerously-skip-permissions', true],
100
104
  ]
101
105
  assert.strict.deepEqual(r, rr)
102
106
  })
@@ -0,0 +1,118 @@
1
+ import assert from 'assert'
2
+ import keys from 'lodash-es/keys.js'
3
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
4
+ import { NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
5
+ import createFakeCli from './tools/fakeCliForTest.mjs'
6
+
7
+
8
+ describe('dispatchAiWkf', function() {
9
+
10
+ let fake = null
11
+ let providers = null
12
+
13
+ before(function() {
14
+ fake = createFakeCli('fake-wkf-factory')
15
+ providers = {
16
+ 'p-a': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
17
+ 'p-b': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
18
+ 'p-int': { kind: 'opencode', exe: fake.exe, model: 'opencode/free' },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('providers無效時throw(設定錯誤於啟動期即失敗)', function() {
29
+ let r = []
30
+ for (let providers of [undefined, null, 'abc', 123, []]) {
31
+ try {
32
+ dispatchAiWkf({ providers })
33
+ r.push('no-throw')
34
+ }
35
+ catch (err) {
36
+ r.push(err.message.indexOf('dispatchAiWkf:') === 0)
37
+ }
38
+ }
39
+ let rr = [true, true, true, true, true]
40
+ assert.strict.deepEqual(r, rr)
41
+ })
42
+
43
+ it('回傳綁定版API物件', function() {
44
+ let wkf = dispatchAiWkf({ providers })
45
+ let r = [
46
+ keys(wkf),
47
+ wkf.providers === providers,
48
+ typeof wkf.callAi,
49
+ typeof wkf.runFanout,
50
+ typeof wkf.runRolePipeline,
51
+ typeof wkf.runFanoutPipeline,
52
+ ]
53
+ let rr = [
54
+ ['providers', 'callAi', 'runFanout', 'runRolePipeline', 'runFanoutPipeline'],
55
+ true,
56
+ 'function',
57
+ 'function',
58
+ 'function',
59
+ 'function',
60
+ ]
61
+ assert.strict.deepEqual(r, rr)
62
+ })
63
+
64
+ it('callAi綁定providers, 以名稱宣告即可呼叫', async function() {
65
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
66
+ let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
67
+ let r = [t.ok, t.providerId, t.json.stdin]
68
+ let rr = [true, 'p-a', 'abc']
69
+ assert.strict.deepEqual(r, rr)
70
+ })
71
+
72
+ it('defaults為共用預設且呼叫時可逐項覆寫', async function() {
73
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: 'DEF:' } })
74
+ let t1 = await wkf.callAi('abc', { spec: { use: 'p-a' } })
75
+ let t2 = await wkf.callAi('abc', { spec: { use: 'p-a' }, promptPrefix: 'OVR:' })
76
+ let r = [t1.json.stdin, t2.json.stdin]
77
+ let rr = ['DEF:abc', 'OVR:abc']
78
+ assert.strict.deepEqual(r, rr)
79
+ })
80
+
81
+ it('未給defaults時預設掛防寫檔前綴', async function() {
82
+ let wkf = dispatchAiWkf({ providers })
83
+ let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
84
+ let r = t.json.stdin
85
+ let rr = NO_SIDE_EFFECT + 'abc'
86
+ assert.strict.deepEqual(r, rr)
87
+ })
88
+
89
+ it('runFanout綁定providers與defaults(defaults併入callOpt)', async function() {
90
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
91
+ let t = await wkf.runFanout({
92
+ task: 'abc',
93
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
94
+ integrate: { use: 'p-int' },
95
+ })
96
+ let r = [t.ok, t.integrated, t.result.stdin.includes('【候選 1】')]
97
+ let rr = [true, true, true]
98
+ assert.strict.deepEqual(r, rr)
99
+ })
100
+
101
+ it('runRolePipeline與runFanoutPipeline皆已綁定可用', async function() {
102
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
103
+ let t1 = await wkf.runRolePipeline({
104
+ input: 'x',
105
+ stages: [{ id: 's1', use: 'p-a', prompt: (ctx) => `on ${ctx.input}` }],
106
+ })
107
+ let t2 = await wkf.runFanoutPipeline({
108
+ task: 'abc',
109
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
110
+ integrate: { use: 'p-int' },
111
+ stages: [{ id: 's1', use: 'p-a', prompt: () => 'y' }],
112
+ })
113
+ let r = [t1.ok, t1.results.s1.stdin, t2.ok, t2.B.order]
114
+ let rr = [true, 'on x', true, ['s1']]
115
+ assert.strict.deepEqual(r, rr)
116
+ })
117
+
118
+ })
@@ -0,0 +1,173 @@
1
+ import assert from 'assert'
2
+ import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ describe('dispatchAntigravity', function() {
7
+
8
+ let fake = null
9
+
10
+ before(function() {
11
+ fake = createFakeCli('fake-agy')
12
+ })
13
+
14
+ after(function() {
15
+ if (fake) {
16
+ fake.clean()
17
+ }
18
+ })
19
+
20
+ it('prompt非有效字串時回傳錯誤結果物件且不reject', async function() {
21
+ let r = []
22
+ for (let prompt of [null, undefined, '', 123, {}, []]) {
23
+ let t = await dispatchAntigravity(prompt)
24
+ r.push([t.ok, t.error, t.attempts])
25
+ }
26
+ let rr = [
27
+ [false, 'prompt must be a non-empty string', 0],
28
+ [false, 'prompt must be a non-empty string', 0],
29
+ [false, 'prompt must be a non-empty string', 0],
30
+ [false, 'prompt must be a non-empty string', 0],
31
+ [false, 'prompt must be a non-empty string', 0],
32
+ [false, 'prompt must be a non-empty string', 0],
33
+ ]
34
+ assert.strict.deepEqual(r, rr)
35
+ })
36
+
37
+ it('prompt超過30000字元時回傳prompt too long且不spawn', async function() {
38
+ let t = await dispatchAntigravity('x'.repeat(30001), { exe: fake.exe })
39
+ let r = [t.ok, t.error.indexOf('prompt too long (30001 chars > 30000)') === 0, t.attempts]
40
+ let rr = [false, true, 0]
41
+ assert.strict.deepEqual(r, rr)
42
+ })
43
+
44
+ it('prompt恰為30000字元時可正常spawn', async function() {
45
+ let prompt = 'x'.repeat(30000)
46
+ let t = await dispatchAntigravity(prompt, { exe: fake.exe })
47
+ let o = JSON.parse(t.stdout)
48
+ let r = [t.ok, o.args[o.args.length - 1] === prompt]
49
+ let rr = [true, true]
50
+ assert.strict.deepEqual(r, rr)
51
+ })
52
+
53
+ it('轉義膨脹突破命令列上限時由try/catch兜底回傳錯誤結果物件不reject', async function() {
54
+ //29000個引號長度過前置檢查, 但spawn轉義後約58000字元 → 同步拋ENAMETOOLONG
55
+ let t = await dispatchAntigravity('"'.repeat(29000), { exe: fake.exe })
56
+ let r = [t.ok, t.error.includes('ENAMETOOLONG'), t.attempts]
57
+ let rr = [false, true, 0]
58
+ assert.strict.deepEqual(r, rr)
59
+ })
60
+
61
+ it('預設帶--dangerously-skip-permissions與--print-timeout, prompt為--print之值且位於最後', async function() {
62
+ let t = await dispatchAntigravity('abc', { exe: fake.exe })
63
+ let o = JSON.parse(t.stdout)
64
+ let r = [t.ok, o.args]
65
+ let rr = [true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--print', 'abc']]
66
+ assert.strict.deepEqual(r, rr)
67
+ })
68
+
69
+ it('prompt不走stdin(agy由--print取得, 塞stdin會進互動模式)', async function() {
70
+ let t = await dispatchAntigravity('abc', { exe: fake.exe })
71
+ let o = JSON.parse(t.stdout)
72
+ let r = [o.stdin, o.args.includes('abc')]
73
+ let rr = ['', true]
74
+ assert.strict.deepEqual(r, rr)
75
+ })
76
+
77
+ it('有給model時帶--model旗標', async function() {
78
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, model: 'gemini-3.6-flash-low' })
79
+ let o = JSON.parse(t.stdout)
80
+ let r = o.args
81
+ let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.6-flash-low', '--print', 'abc']
82
+ assert.strict.deepEqual(r, rr)
83
+ })
84
+
85
+ it('有給effort時帶--effort旗標', async function() {
86
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, model: 'gemini-3.1-pro', effort: 'low' })
87
+ let o = JSON.parse(t.stdout)
88
+ let r = o.args
89
+ let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.1-pro', '--effort', 'low', '--print', 'abc']
90
+ assert.strict.deepEqual(r, rr)
91
+ })
92
+
93
+ it('skipPermissions為false時不帶--dangerously-skip-permissions旗標', async function() {
94
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, skipPermissions: false })
95
+ let o = JSON.parse(t.stdout)
96
+ let r = [t.ok, o.args]
97
+ let rr = [true, ['--print-timeout', '270s', '--print', 'abc']]
98
+ assert.strict.deepEqual(r, rr)
99
+ })
100
+
101
+ it('printTimeout由timeoutMs推導並預留30秒緩衝, 下限30秒', async function() {
102
+ let r = []
103
+ for (let timeoutMs of [600000, 120000, 5000, undefined]) {
104
+ let opt = { exe: fake.exe }
105
+ if (timeoutMs !== undefined) {
106
+ opt.timeoutMs = timeoutMs
107
+ }
108
+ let t = await dispatchAntigravity('abc', opt)
109
+ let o = JSON.parse(t.stdout)
110
+ let i = o.args.indexOf('--print-timeout')
111
+ r.push(o.args[i + 1])
112
+ }
113
+ let rr = ['570s', '90s', '30s', '270s'] //600-30, 120-30, max(30,5-30), 預設300-30
114
+ assert.strict.deepEqual(r, rr)
115
+ })
116
+
117
+ it('printTimeout明確給定時不推導', async function() {
118
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, printTimeout: '10m', timeoutMs: 600000 })
119
+ let o = JSON.parse(t.stdout)
120
+ let i = o.args.indexOf('--print-timeout')
121
+ let r = o.args[i + 1]
122
+ let rr = '10m'
123
+ assert.strict.deepEqual(r, rr)
124
+ })
125
+
126
+ it('addDirs逐項展開為--add-dir並濾除非有效字串', async function() {
127
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, addDirs: ['c:/a', null, 'c:/b', 123] })
128
+ let o = JSON.parse(t.stdout)
129
+ let r = o.args
130
+ let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--add-dir', 'c:/a', '--add-dir', 'c:/b', '--print', 'abc']
131
+ assert.strict.deepEqual(r, rr)
132
+ })
133
+
134
+ it('extraArgs接於固定旗標之後且於--print之前', async function() {
135
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, extraArgs: ['--output-format', 'json'] })
136
+ let o = JSON.parse(t.stdout)
137
+ let r = o.args
138
+ let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--output-format', 'json', '--print', 'abc']
139
+ assert.strict.deepEqual(r, rr)
140
+ })
141
+
142
+ it('中文與多行prompt於--print值內完整保留', async function() {
143
+ let prompt = '第一行中文\n第二行 with "quote" & <sym>\n第三行'
144
+ let t = await dispatchAntigravity(prompt, { exe: fake.exe })
145
+ let o = JSON.parse(t.stdout)
146
+ let r = o.args[o.args.length - 1] === prompt
147
+ let rr = true
148
+ assert.strict.deepEqual(r, rr)
149
+ })
150
+
151
+ it('執行檔不存在時回傳ENOENT且不重試', async function() {
152
+ let t = await dispatchAntigravity('abc', { exe: 'agy-not-exist-for-test', maxRetries: 3 })
153
+ let r = [t.ok, t.error.includes('ENOENT'), t.attempts]
154
+ let rr = [false, true, 1]
155
+ assert.strict.deepEqual(r, rr)
156
+ })
157
+
158
+ it('離開碼非0時回傳失敗(如model與effort衝突時agy回exit 1)', async function() {
159
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, extraArgs: ['--fake-exit=1', '--fake-stderr=conflicts with --effort'] })
160
+ let r = [t.ok, t.code, t.error, t.stderr]
161
+ let rr = [false, 1, 'Exit code 1', 'conflicts with --effort']
162
+ assert.strict.deepEqual(r, rr)
163
+ })
164
+
165
+ it('cwd等execCli設定原樣轉傳', async function() {
166
+ let t = await dispatchAntigravity('abc', { exe: fake.exe, cwd: process.cwd() })
167
+ let o = JSON.parse(t.stdout)
168
+ let r = [t.ok, o.cwd === process.cwd()]
169
+ let rr = [true, true]
170
+ assert.strict.deepEqual(r, rr)
171
+ })
172
+
173
+ })