w-dispatch-ai 1.0.2 → 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 (44) hide show
  1. package/README.md +60 -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 +8 -4
  5. package/docs/adapters.mjs.html +9 -7
  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 +2 -2
  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 +5747 -1467
  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 +11 -2
  24. package/package.json +1 -1
  25. package/src/WDispatchAi.mjs +6 -2
  26. package/src/adapters.mjs +7 -5
  27. package/src/dispatchAiWkf.mjs +100 -0
  28. package/src/dispatchApiOpenaiCompat.mjs +411 -0
  29. package/src/wkf/callAiWithFallback.mjs +209 -0
  30. package/src/wkf/extractJsonLoose.mjs +108 -0
  31. package/src/wkf/runFanout.mjs +155 -0
  32. package/src/wkf/runFanoutPipeline.mjs +106 -0
  33. package/src/wkf/runRolePipeline.mjs +123 -0
  34. package/test/tools/fakeServerForApiTest.mjs +137 -0
  35. package/test/unit-WDispatchAi.test.mjs +13 -6
  36. package/test/unit-adapters.test.mjs +5 -3
  37. package/test/unit-callAiWithFallback.test.mjs +146 -0
  38. package/test/unit-dispatchAi.test.mjs +1 -1
  39. package/test/unit-dispatchAiWkf.test.mjs +118 -0
  40. package/test/unit-dispatchApiOpenaiCompat.test.mjs +233 -0
  41. package/test/unit-extractJsonLoose.test.mjs +78 -0
  42. package/test/unit-runFanout.test.mjs +166 -0
  43. package/test/unit-runFanoutPipeline.test.mjs +86 -0
  44. package/test/unit-runRolePipeline.test.mjs +163 -0
@@ -0,0 +1,233 @@
1
+ import assert from 'assert'
2
+ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
3
+ import dispatchAi from '../src/dispatchAi.mjs'
4
+ import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
5
+ import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
6
+ import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
7
+
8
+
9
+ describe('dispatchApiOpenaiCompat', function() {
10
+
11
+ let svr = null
12
+
13
+ before(async function() {
14
+ svr = await fakeServerForApiTest()
15
+ })
16
+
17
+ after(async function() {
18
+ if (svr) {
19
+ await svr.close()
20
+ }
21
+ })
22
+
23
+ it('prompt非有效字串時回傳錯誤結果物件且不reject', async function() {
24
+ let r = []
25
+ for (let prompt of [null, undefined, '', 123, {}, []]) {
26
+ let t = await dispatchApiOpenaiCompat(prompt)
27
+ r.push([t.ok, t.error, t.attempts])
28
+ }
29
+ let rr = [
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
+ [false, 'prompt must be a non-empty string', 0],
34
+ [false, 'prompt must be a non-empty string', 0],
35
+ [false, 'prompt must be a non-empty string', 0],
36
+ ]
37
+ assert.strict.deepEqual(r, rr)
38
+ })
39
+
40
+ it('baseURL與model必填, 缺一即回傳錯誤結果物件', async function() {
41
+ let t1 = await dispatchApiOpenaiCompat('abc', { model: 'echo' })
42
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url })
43
+ let r = [[t1.ok, t1.error], [t2.ok, t2.error]]
44
+ let rr = [
45
+ [false, 'baseURL must be a non-empty string'],
46
+ [false, 'model must be a non-empty string'],
47
+ ]
48
+ assert.strict.deepEqual(r, rr)
49
+ })
50
+
51
+ it('成功時stdout為回覆內容, 請求含Bearer金鑰與model與user訊息', async function() {
52
+ let t = await dispatchApiOpenaiCompat('中文prompt\n第二行', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
53
+ let o = JSON.parse(t.stdout)
54
+ let r = [
55
+ t.ok,
56
+ t.code,
57
+ t.error,
58
+ t.attempts,
59
+ o.auth,
60
+ o.body.model,
61
+ o.body.messages,
62
+ ]
63
+ let rr = [
64
+ true,
65
+ 200,
66
+ '',
67
+ 1,
68
+ 'Bearer sk-good-1',
69
+ 'echo',
70
+ [{ role: 'user', content: '中文prompt\n第二行' }],
71
+ ]
72
+ assert.strict.deepEqual(r, rr)
73
+ })
74
+
75
+ it('未給key時不帶Authorization標頭', async function() {
76
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, model: 'echo' })
77
+ let o = JSON.parse(t.stdout)
78
+ let r = [t.ok, o.auth]
79
+ let rr = [true, '']
80
+ assert.strict.deepEqual(r, rr)
81
+ })
82
+
83
+ it('baseURL尾端斜線自動正規化', async function() {
84
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url + '///', key: 'sk-good-1', model: 'echo' })
85
+ let r = [t.ok, t.code]
86
+ let rr = [true, 200]
87
+ assert.strict.deepEqual(r, rr)
88
+ })
89
+
90
+ it('system置於messages首位, body額外鍵可併入', async function() {
91
+ let t = await dispatchApiOpenaiCompat('abc', {
92
+ baseURL: svr.url,
93
+ key: 'sk-good-1',
94
+ model: 'echo',
95
+ system: '你是審計者',
96
+ body: { temperature: 0.2, max_tokens: 100 },
97
+ })
98
+ let o = JSON.parse(t.stdout)
99
+ let r = [
100
+ o.body.messages,
101
+ o.body.temperature,
102
+ o.body.max_tokens,
103
+ ]
104
+ let rr = [
105
+ [{ role: 'system', content: '你是審計者' }, { role: 'user', content: 'abc' }],
106
+ 0.2,
107
+ 100,
108
+ ]
109
+ assert.strict.deepEqual(r, rr)
110
+ })
111
+
112
+ it('無效金鑰回401且不重試(4xx為客戶端錯誤)', async function() {
113
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-bad-x', model: 'echo', maxRetries: 3, retryDelayMs: 100 })
114
+ let r = [t.ok, t.code, t.error, t.attempts, t.stderr.includes('Invalid API key')]
115
+ let rr = [false, 401, 'HTTP 401', 1, true]
116
+ assert.strict.deepEqual(r, rr)
117
+ })
118
+
119
+ it('429依maxRetries重試後成功', async function() {
120
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-flaky1', model: 'flaky-429', maxRetries: 1, retryDelayMs: 100 })
121
+ let r = [t.ok, t.code, t.attempts, JSON.parse(t.stdout).attempt]
122
+ let rr = [true, 200, 2, 2]
123
+ assert.strict.deepEqual(r, rr)
124
+ })
125
+
126
+ it('429且maxRetries為0時直接失敗', async function() {
127
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-flaky2', model: 'flaky-429' })
128
+ let r = [t.ok, t.code, t.error, t.attempts]
129
+ let rr = [false, 429, 'HTTP 429', 1]
130
+ assert.strict.deepEqual(r, rr)
131
+ })
132
+
133
+ it('HTTP 500回傳失敗且stderr含回應本體', async function() {
134
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'err-500' })
135
+ let r = [t.ok, t.code, t.error, t.stderr.includes('internal error')]
136
+ let rr = [false, 500, 'HTTP 500', true]
137
+ assert.strict.deepEqual(r, rr)
138
+ })
139
+
140
+ it('逾時時中止請求且error以TIMEOUT開頭', async function() {
141
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'slow', timeoutMs: 1000 })
142
+ let r = [t.ok, t.code, t.error, t.durationMs < 9000]
143
+ let rr = [false, null, 'TIMEOUT after 1s', true]
144
+ assert.strict.deepEqual(r, rr)
145
+ })
146
+
147
+ it('網路層錯誤回傳FETCH_ERROR', async function() {
148
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: 'http://127.0.0.1:1/v1', key: 'sk-good-1', model: 'echo', timeoutMs: 3000 })
149
+ let r = [t.ok, t.code, t.error.indexOf('FETCH_ERROR') === 0]
150
+ let rr = [false, null, true]
151
+ assert.strict.deepEqual(r, rr)
152
+ })
153
+
154
+ it('回應缺choices時回傳INVALID_RESPONSE', async function() {
155
+ let r = []
156
+ for (let model of ['no-choices', 'not-json']) {
157
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
158
+ r.push([t.ok, t.code, t.error.indexOf('INVALID_RESPONSE') === 0])
159
+ }
160
+ let rr = [[false, 200, true], [false, 200, true]]
161
+ assert.strict.deepEqual(r, rr)
162
+ })
163
+
164
+ it('validate字串規則與自訂函數皆可用, 失敗回傳OUTPUT_VALIDATION_FAILED', async function() {
165
+ let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
166
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'nonempty,json' })
167
+ let t3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: (s) => s.includes('絕不存在的字串') })
168
+ let r = [
169
+ [t1.ok, t1.code, t1.error],
170
+ [t2.ok, t2.error],
171
+ [t3.ok, t3.error],
172
+ ]
173
+ let rr = [
174
+ [false, 200, 'OUTPUT_VALIDATION_FAILED'],
175
+ [true, ''],
176
+ [false, 'OUTPUT_VALIDATION_FAILED'],
177
+ ]
178
+ assert.strict.deepEqual(r, rr)
179
+ })
180
+
181
+ it('可經dispatchAi以kind api-openai-compat分派', async function() {
182
+ let t = await dispatchAi('api-openai-compat', 'abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
183
+ let o = JSON.parse(t.stdout)
184
+ let r = [t.ok, o.body.model]
185
+ let rr = [true, 'echo']
186
+ assert.strict.deepEqual(r, rr)
187
+ })
188
+
189
+ it('可作為dispatchAiFallback條目, 多金鑰輪替(401換下一把)', async function() {
190
+ let t = await dispatchAiFallback('abc', {
191
+ providers: [{
192
+ id: 'g-api',
193
+ kind: 'api-openai-compat',
194
+ baseURL: svr.url,
195
+ model: 'echo',
196
+ keys: ['sk-bad-r0', 'sk-good-r1'],
197
+ }],
198
+ })
199
+ let o = JSON.parse(t.stdout)
200
+ let r = [
201
+ t.ok,
202
+ t.providerId,
203
+ t.keyIndex,
204
+ o.auth,
205
+ t.tried.map((x) => [x.keyId, x.outcome, x.error || null]),
206
+ ]
207
+ let rr = [
208
+ true,
209
+ 'g-api',
210
+ 1,
211
+ 'Bearer sk-good-r1',
212
+ [['g-api#0', 'next-key', 'HTTP 401'], ['g-api#1', 'ok', null]],
213
+ ]
214
+ assert.strict.deepEqual(r, rr)
215
+ })
216
+
217
+ it('可作為工作流provider, 回覆JSON經解析與檢核', async function() {
218
+ let wkf = dispatchAiWkf({
219
+ providers: {
220
+ 'api-echo': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-w0'] },
221
+ },
222
+ defaults: { promptPrefix: '' },
223
+ })
224
+ let t = await wkf.callAi('abc', {
225
+ spec: { use: 'api-echo' },
226
+ check: (j) => j.body.model === 'echo',
227
+ })
228
+ let r = [t.ok, t.providerId, t.keyId, t.json.auth, t.json.body.messages[0].content]
229
+ let rr = [true, 'api-echo', 'api-echo#0', 'Bearer sk-good-w0', 'abc']
230
+ assert.strict.deepEqual(r, rr)
231
+ })
232
+
233
+ })
@@ -0,0 +1,78 @@
1
+ import assert from 'assert'
2
+ import extractJsonLoose from '../src/wkf/extractJsonLoose.mjs'
3
+
4
+
5
+ describe('extractJsonLoose', function() {
6
+
7
+ it('整段即為合法JSON時直接解析', function() {
8
+ let r = [
9
+ extractJsonLoose('{"a":1}'),
10
+ extractJsonLoose('[1,2,3]'),
11
+ extractJsonLoose(' {"a":{"b":[1]}} '),
12
+ ]
13
+ let rr = [{ a: 1 }, [1, 2, 3], { a: { b: [1] } }]
14
+ assert.strict.deepEqual(r, rr)
15
+ })
16
+
17
+ it('去除code fence與前後說明文字後抽取第一個完整物件', function() {
18
+ let r = [
19
+ extractJsonLoose('說明文字\n```json\n{"a":1}\n```\n後記'),
20
+ extractJsonLoose('```\n{"a":1}\n```'),
21
+ extractJsonLoose('前綴 {"a":1} 後綴 {"b":2}'),
22
+ ]
23
+ let rr = [{ a: 1 }, { a: 1 }, { a: 1 }]
24
+ assert.strict.deepEqual(r, rr)
25
+ })
26
+
27
+ it('去除ANSI色碼', function() {
28
+ let esc = String.fromCharCode(27)
29
+ let r = extractJsonLoose(`${esc}[0m{"a":1}${esc}[91m`)
30
+ let rr = { a: 1 }
31
+ assert.strict.deepEqual(r, rr)
32
+ })
33
+
34
+ it('字串值內之括號與跳脫不干擾配對', function() {
35
+ let r = extractJsonLoose('{"a":"x{y}[z]","b":"q\\"w"}')
36
+ let rr = { a: 'x{y}[z]', b: 'q"w' }
37
+ assert.strict.deepEqual(r, rr)
38
+ })
39
+
40
+ it('截斷(括號未閉合)回傳null', function() {
41
+ let r = [
42
+ extractJsonLoose('{"a":1'),
43
+ extractJsonLoose('[1,2'),
44
+ extractJsonLoose('{"a":"未閉合字串'),
45
+ ]
46
+ let rr = [null, null, null]
47
+ assert.strict.deepEqual(r, rr)
48
+ })
49
+
50
+ it('非物件與陣列之JSON回傳null', function() {
51
+ let r = [
52
+ extractJsonLoose('"hello"'),
53
+ extractJsonLoose('123'),
54
+ extractJsonLoose('true'),
55
+ extractJsonLoose('null'),
56
+ ]
57
+ let rr = [null, null, null, null]
58
+ assert.strict.deepEqual(r, rr)
59
+ })
60
+
61
+ it('無JSON內容回傳null', function() {
62
+ let r = [
63
+ extractJsonLoose('純文字回覆'),
64
+ extractJsonLoose(''),
65
+ extractJsonLoose(null),
66
+ extractJsonLoose(undefined),
67
+ ]
68
+ let rr = [null, null, null, null]
69
+ assert.strict.deepEqual(r, rr)
70
+ })
71
+
72
+ it('第一個片段非法時回傳null(不繼續嘗試後續片段)', function() {
73
+ let r = extractJsonLoose('{"a":1,} {"b":2}')
74
+ let rr = null
75
+ assert.strict.deepEqual(r, rr)
76
+ })
77
+
78
+ })
@@ -0,0 +1,166 @@
1
+ import assert from 'assert'
2
+ import runFanout, { defaultIntegratePrompt } from '../src/wkf/runFanout.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ describe('runFanout', function() {
7
+
8
+ let fake = null
9
+ let providers = null
10
+ let callOpt = { promptPrefix: '' }
11
+
12
+ before(function() {
13
+ fake = createFakeCli('fake-wkf-fanout')
14
+ providers = {
15
+ 'p-a': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
16
+ 'p-b': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
17
+ 'p-int': { kind: 'opencode', exe: fake.exe, model: 'opencode/free' },
18
+ 'p-dead': { kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('task或agents無效時回傳錯誤結果物件', async function() {
29
+ let t1 = await runFanout({ providers, task: '', agents: [{ use: 'p-a' }], callOpt })
30
+ let t2 = await runFanout({ providers, task: 'abc', agents: null, callOpt })
31
+ let r = [[t1.ok, t1.error], [t2.ok, t2.error]]
32
+ let rr = [
33
+ [false, 'task must be a non-empty string'],
34
+ [false, 'agents must be a non-empty array'],
35
+ ]
36
+ assert.strict.deepEqual(r, rr)
37
+ })
38
+
39
+ it('候選達門檻時進整合, 整合提示詞含各候選', async function() {
40
+ let t = await runFanout({
41
+ providers,
42
+ task: 'abc',
43
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
44
+ integrate: { use: 'p-int' },
45
+ callOpt,
46
+ })
47
+ let r = [
48
+ t.ok,
49
+ t.integrated,
50
+ t.candidates.length,
51
+ t.agents.map((x) => x.providerId),
52
+ t.result.args[0], //整合者為opencode轉接器
53
+ t.result.stdin.includes('【候選 1】') && t.result.stdin.includes('【候選 2】'),
54
+ t.integrateDetail.providerId,
55
+ ]
56
+ let rr = [true, true, 2, ['p-a', 'p-b'], 'run', true, 'p-int']
57
+ assert.strict.deepEqual(r, rr)
58
+ })
59
+
60
+ it('個別名額失敗不炸整輪, 恰1份成功候選直接放行(integrated:false)', async function() {
61
+ let t = await runFanout({
62
+ providers,
63
+ task: 'abc',
64
+ agents: [{ use: 'p-dead' }, { use: 'p-a' }],
65
+ integrate: { use: 'p-int' },
66
+ callOpt,
67
+ })
68
+ let r = [t.ok, t.integrated, t.candidates.length, t.result.args, t.agents[0].ok, t.agents[1].ok]
69
+ let rr = [true, false, 1, ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], false, true]
70
+ assert.strict.deepEqual(r, rr)
71
+ })
72
+
73
+ it('候選未達minCandidates時不硬整合, 以首位候選為成果', async function() {
74
+ let t = await runFanout({
75
+ providers,
76
+ task: 'abc',
77
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
78
+ integrate: { use: 'p-int' },
79
+ minCandidates: 3,
80
+ callOpt,
81
+ })
82
+ let r = [t.ok, t.integrated, t.candidates.length, t.result === t.candidates[0], t.error]
83
+ let rr = [true, false, 2, true, '']
84
+ assert.strict.deepEqual(r, rr)
85
+ })
86
+
87
+ it('全數名額失敗時回傳all agents failed', async function() {
88
+ let t = await runFanout({
89
+ providers,
90
+ task: 'abc',
91
+ agents: [{ use: 'p-dead' }, { use: 'p-dead' }],
92
+ integrate: { use: 'p-int' },
93
+ callOpt,
94
+ })
95
+ let r = [t.ok, t.result, t.candidates.length, t.error]
96
+ let rr = [false, null, 0, 'all agents failed']
97
+ assert.strict.deepEqual(r, rr)
98
+ })
99
+
100
+ it('達門檻但缺integrate規格時回報必要性', async function() {
101
+ let t = await runFanout({
102
+ providers,
103
+ task: 'abc',
104
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
105
+ callOpt,
106
+ })
107
+ let r = [t.ok, t.error, t.candidates.length]
108
+ let rr = [false, 'integrate spec (with use) is required', 2]
109
+ assert.strict.deepEqual(r, rr)
110
+ })
111
+
112
+ it('整合失敗時成功候選仍完整回傳供接續重試', async function() {
113
+ let t = await runFanout({
114
+ providers,
115
+ task: 'abc',
116
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
117
+ integrate: { use: 'p-dead' },
118
+ callOpt,
119
+ })
120
+ let r = [t.ok, t.integrated, t.candidates.length, t.error.indexOf('integrate failed:') === 0]
121
+ let rr = [false, false, 2, true]
122
+ assert.strict.deepEqual(r, rr)
123
+ })
124
+
125
+ it('integrate.prompt可自訂整合提示詞', async function() {
126
+ let seen = null
127
+ let t = await runFanout({
128
+ providers,
129
+ task: 'abc',
130
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
131
+ integrate: {
132
+ use: 'p-int',
133
+ prompt: (candidates) => {
134
+ seen = candidates.length
135
+ return 'CUSTOM-INTEGRATE'
136
+ },
137
+ },
138
+ callOpt,
139
+ })
140
+ let r = [t.ok, seen, t.result.stdin]
141
+ let rr = [true, 2, 'CUSTOM-INTEGRATE']
142
+ assert.strict.deepEqual(r, rr)
143
+ })
144
+
145
+ it('名額規格之額外鍵覆寫該名額呼叫設定', async function() {
146
+ //p-a名額帶rawText覆寫 → 該名額json為文字, 候選為字串
147
+ let t = await runFanout({
148
+ providers,
149
+ task: 'abc',
150
+ agents: [{ use: 'p-a', rawText: true }],
151
+ integrate: { use: 'p-int' },
152
+ callOpt,
153
+ })
154
+ let r = [t.ok, t.integrated, typeof t.result]
155
+ let rr = [true, false, 'string']
156
+ assert.strict.deepEqual(r, rr)
157
+ })
158
+
159
+ it('defaultIntegratePrompt嵌入候選數與schema', function() {
160
+ let s = defaultIntegratePrompt([{ a: 1 }, { b: 2 }], { schema: '{"x":"..."}' })
161
+ let r = [s.includes('2 個獨立執行'), s.includes('{"x":"..."}'), s.includes('【候選 2】')]
162
+ let rr = [true, true, true]
163
+ assert.strict.deepEqual(r, rr)
164
+ })
165
+
166
+ })
@@ -0,0 +1,86 @@
1
+ import assert from 'assert'
2
+ import runFanoutPipeline from '../src/wkf/runFanoutPipeline.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ describe('runFanoutPipeline', function() {
7
+
8
+ let fake = null
9
+ let providers = null
10
+ let callOpt = { promptPrefix: '' }
11
+
12
+ before(function() {
13
+ fake = createFakeCli('fake-wkf-fp')
14
+ providers = {
15
+ 'p-a': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
16
+ 'p-b': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
17
+ 'p-int': { kind: 'opencode', exe: fake.exe, model: 'opencode/free' },
18
+ 'p-dead': { kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
19
+ }
20
+ })
21
+
22
+ after(function() {
23
+ if (fake) {
24
+ fake.clean()
25
+ }
26
+ })
27
+
28
+ it('前段多開整合, 後段以前段成果為input執行角色鏈', async function() {
29
+ let t = await runFanoutPipeline({
30
+ providers,
31
+ task: 'abc',
32
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
33
+ integrate: { use: 'p-int' },
34
+ stages: [
35
+ { id: 'audit', use: 'p-b', prompt: (ctx) => `audit ${ctx.input.args[0]}` }, //input為整合者echo物件
36
+ ],
37
+ callOpt,
38
+ })
39
+ let r = [
40
+ t.ok,
41
+ t.A.ok,
42
+ t.A.integrated,
43
+ t.B.ok,
44
+ t.B.order,
45
+ t.result.stdin, //後段最末階段之echo, 其stdin含前段成果之args[0](整合者為opencode轉接器故為run)
46
+ ]
47
+ let rr = [true, true, true, true, ['audit'], 'audit run']
48
+ assert.strict.deepEqual(r, rr)
49
+ })
50
+
51
+ it('前段失敗即回, 後段不執行', async function() {
52
+ let t = await runFanoutPipeline({
53
+ providers,
54
+ task: 'abc',
55
+ agents: [{ use: 'p-dead' }, { use: 'p-dead' }],
56
+ integrate: { use: 'p-int' },
57
+ stages: [{ id: 's1', use: 'p-a', prompt: () => 'x' }],
58
+ callOpt,
59
+ })
60
+ let r = [t.ok, t.A.ok, t.B, t.error.indexOf('A failed:') === 0]
61
+ let rr = [false, false, null, true]
62
+ assert.strict.deepEqual(r, rr)
63
+ })
64
+
65
+ it('後段失敗時前段成果仍完整回傳, 可只重跑後段', async function() {
66
+ let t = await runFanoutPipeline({
67
+ providers,
68
+ task: 'abc',
69
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
70
+ integrate: { use: 'p-int' },
71
+ stages: [{ id: 's1', use: 'p-dead', prompt: () => 'x' }],
72
+ callOpt,
73
+ })
74
+ let r = [
75
+ t.ok,
76
+ t.A.ok,
77
+ t.A.result !== null,
78
+ t.B.ok,
79
+ t.B.failedStage,
80
+ t.error.indexOf('B failed:') === 0,
81
+ ]
82
+ let rr = [false, true, true, false, 's1', true]
83
+ assert.strict.deepEqual(r, rr)
84
+ })
85
+
86
+ })