@mzzsfy/dsh-keep-working 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,436 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resolveConfig } from '../src/config.mjs'
5
+ import { createEngine } from '../src/engine.mjs'
6
+
7
+ const TURN = 1
8
+ const START_TIME = 1_000_000
9
+
10
+ const event = (seq, type, data) => ({ seq, time: seq, type, data })
11
+
12
+ const turnStart = (seq, turn, reason) =>
13
+ event(seq, 'turn/start', reason === undefined ? { turn } : { turn, reason })
14
+
15
+ const turnEnd = (seq, turn, reason) => event(seq, 'turn/end', { turn, reason })
16
+
17
+ const assistant = (seq, turn, text) =>
18
+ event(seq, 'assistant/message', {
19
+ turn,
20
+ step: 1,
21
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
22
+ stream: [],
23
+ })
24
+
25
+ const toolCall = (seq, turn, name) =>
26
+ event(seq, 'tool/call', { turn, step: 1, callId: `c-${seq}`, name, arguments: '{}' })
27
+
28
+ const presented = (seq, turn) =>
29
+ event(seq, 'deliverables/presented', { turn, callId: `c-${seq}`, files: [{ path: 'out.md' }] })
30
+
31
+ const userMessage = (seq, kind = 'user', id = `m-${seq}`) =>
32
+ event(seq, 'user/message', { id, source: { kind }, content: [{ type: 'text', text: 'go' }] })
33
+
34
+ function createFakeTimers(clock) {
35
+ let next = 0
36
+ const pending = new Map()
37
+ return {
38
+ setTimeout(fn, ms) {
39
+ const id = (next += 1)
40
+ pending.set(id, { fn, at: clock() + ms })
41
+ return id
42
+ },
43
+ clearTimeout(id) {
44
+ pending.delete(id)
45
+ },
46
+ flush() {
47
+ const jobs = [...pending.entries()].sort((left, right) => left[1].at - right[1].at)
48
+ pending.clear()
49
+ for (const [, job] of jobs) job.fn()
50
+ },
51
+ count: () => pending.size,
52
+ }
53
+ }
54
+
55
+ function createHarness({ origin, header, config: overrides, probe } = {}) {
56
+ let time = START_TIME
57
+ const events = []
58
+ const sent = []
59
+ const handlers = new Map()
60
+ const timers = createFakeTimers(() => time)
61
+ const session = { id: 's1', header: header ?? (origin === undefined ? {} : { origin }), snapshotEvents: () => events }
62
+ const agent = {
63
+ id: 's1',
64
+ session,
65
+ inbox: { nextTurn: [] },
66
+ followup: (message) => sent.push(message),
67
+ }
68
+ const ctx = {
69
+ agents: {
70
+ get: (id) => (id === 's1' ? agent : undefined),
71
+ list: () => [agent],
72
+ },
73
+ logger: { info() {}, warn() {} },
74
+ on: (name, handler) => {
75
+ handlers.set(name, handler)
76
+ return () => handlers.delete(name)
77
+ },
78
+ }
79
+ const engine = createEngine({
80
+ ctx,
81
+ config: resolveConfig(overrides),
82
+ timers,
83
+ now: () => time,
84
+ createUserMessage: (options) => ({ id: `auto-${sent.length + 1}`, ...options }),
85
+ probe,
86
+ })
87
+ engine.attach()
88
+ const emit = (item) => {
89
+ events.push(item)
90
+ handlers.get('session/event')?.(session, item)
91
+ }
92
+ // 插件 followup 启动的回合,首条 user/message 就是它投递的那条;
93
+ // 夹具按真实事件序复用它,human=true 才模拟真人新回合
94
+ const runTurn = (seq, turn, text = '没结案', { human = false } = {}) => {
95
+ emit(turnStart(seq, turn))
96
+ const lastId = sent.at(-1)?.id
97
+ emit(userMessage(seq + 1, 'user', human || lastId === undefined ? `m-${seq + 1}` : lastId))
98
+ emit(assistant(seq + 2, turn, text))
99
+ emit(turnEnd(seq + 3, turn, { kind: 'completed' }))
100
+ timers.flush()
101
+ }
102
+ return {
103
+ engine,
104
+ emit,
105
+ sent,
106
+ timers,
107
+ runTurn,
108
+ session,
109
+ advanceTo: (value) => {
110
+ time = value
111
+ },
112
+ now: () => time,
113
+ }
114
+ }
115
+
116
+ const textOf = (message) => message.content.map((block) => block.text).join('')
117
+
118
+ test('S1 有文件写入且无 present:登记一次并发送结案提醒', () => {
119
+ const h = createHarness()
120
+ h.emit(turnStart(0, TURN))
121
+ h.emit(userMessage(1))
122
+ h.emit(toolCall(2, TURN, 'write'))
123
+ h.emit(assistant(3, TURN, '改好了'))
124
+ h.emit(turnEnd(4, TURN, { kind: 'completed' }))
125
+ assert.equal(h.timers.count(), 1)
126
+ h.timers.flush()
127
+ assert.equal(h.sent.length, 1)
128
+ assert.match(textOf(h.sent[0]), /TASK_DONE/)
129
+ })
130
+
131
+ test('S2 末行 TASK_DONE:不发送', () => {
132
+ const h = createHarness()
133
+ h.emit(turnStart(0, TURN))
134
+ h.emit(userMessage(1))
135
+ h.emit(assistant(2, TURN, '结论\nTASK_DONE'))
136
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
137
+ assert.equal(h.timers.count(), 0)
138
+ h.timers.flush()
139
+ assert.equal(h.sent.length, 0)
140
+ })
141
+
142
+ test('S3 子会话:不发送', () => {
143
+ const h = createHarness({ origin: 'subagent' })
144
+ h.emit(turnStart(0, TURN))
145
+ h.emit(userMessage(1))
146
+ h.emit(assistant(2, TURN, '没结案'))
147
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
148
+ assert.equal(h.timers.count(), 0)
149
+ })
150
+
151
+ test('S3b 旧格式子会话(parentSession + delegationDepth):不发送;普通 fork 照常', () => {
152
+ for (const header of [
153
+ { parentSession: 'root', delegationDepth: 1 },
154
+ { parentSession: 'root', delegationDepth: 2 },
155
+ ]) {
156
+ const h = createHarness({ header })
157
+ h.emit(turnStart(0, TURN))
158
+ h.emit(userMessage(1))
159
+ h.emit(assistant(2, TURN, '没结案'))
160
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
161
+ assert.equal(h.timers.count(), 0)
162
+ }
163
+ const fork = createHarness({ header: { parentSession: 'root', delegationDepth: 0 } })
164
+ fork.emit(turnStart(0, TURN))
165
+ fork.emit(userMessage(1))
166
+ fork.emit(assistant(2, TURN, '没结案'))
167
+ fork.emit(turnEnd(3, TURN, { kind: 'completed' }))
168
+ assert.ok(fork.timers.count() > 0, 'depth 0 是普通 fork,不得按子会话跳过')
169
+ })
170
+
171
+ test('S4 连续上限:达到 maxConsecutive 后不再发送', () => {
172
+ const h = createHarness({ config: { maxConsecutive: 2 } })
173
+ // 回合号必须逐轮递增:turnSlice 按首个匹配回合切片,复用 id 会读到上一轮事件
174
+ const run = (seq, turn) => h.runTurn(seq, turn)
175
+ run(0, 1)
176
+ assert.equal(h.sent.length, 1)
177
+ h.advanceTo(h.now() + 60_000)
178
+ run(10, 2)
179
+ assert.equal(h.sent.length, 2)
180
+ h.advanceTo(h.now() + 600_000)
181
+ run(20, 3)
182
+ assert.equal(h.sent.length, 2)
183
+ })
184
+
185
+ test('S4b 正常结案清零连续计数:封顶后仍可再次恢复', () => {
186
+ const h = createHarness({ config: { maxConsecutive: 2 } })
187
+ const run = (seq, turn, text = '没结案') => h.runTurn(seq, turn, text)
188
+ run(0, 1)
189
+ h.advanceTo(h.now() + 60_000)
190
+ run(10, 2)
191
+ assert.equal(h.sent.length, 2)
192
+ h.advanceTo(h.now() + 600_000)
193
+ run(20, 3)
194
+ assert.equal(h.sent.length, 2, '达到上限后不再发送')
195
+ run(30, 4, '结论\nTASK_DONE')
196
+ assert.equal(h.sent.length, 2, '结案本身不发送')
197
+ h.advanceTo(h.now() + 600_000)
198
+ run(40, 5)
199
+ assert.equal(h.sent.length, 3, '结案已清零计数,下一轮重新获得额度')
200
+ })
201
+
202
+ test('S4c 封顶后用户重新发起回合:额度恢复', () => {
203
+ const h = createHarness({ config: { maxConsecutive: 1 } })
204
+ const run = (seq, turn) => h.runTurn(seq, turn)
205
+ run(0, 1)
206
+ assert.equal(h.sent.length, 1)
207
+ h.advanceTo(h.now() + 60_000)
208
+ run(10, 2)
209
+ assert.equal(h.sent.length, 1, '插件自己 followup 启动的回合不得重置')
210
+ // 封顶后用户手动开新回合:没有待消费的插件回合,视为接管
211
+ h.runTurn(20, 3, '没结案', { human: true })
212
+ assert.equal(h.sent.length, 2, '用户接管后恢复一次额度')
213
+ })
214
+
215
+ test('S4d 自投递回合未真正开始:用户回合仍恢复额度', () => {
216
+ const h = createHarness({ config: { maxConsecutive: 1 } })
217
+ h.runTurn(0, 1)
218
+ assert.equal(h.sent.length, 1)
219
+ // followup 被宿主吞掉、没有新回合:下一轮真人回合不得被当成插件自己的回合
220
+ h.advanceTo(h.now() + 600_000)
221
+ h.runTurn(10, 2, '没结案', { human: true })
222
+ assert.equal(h.sent.length, 2)
223
+ })
224
+
225
+ test('S5 用户中途插话:让路,不发送', () => {
226
+ const h = createHarness()
227
+ h.emit(turnStart(0, TURN))
228
+ h.emit(userMessage(1))
229
+ h.emit(assistant(2, TURN, '先看看'))
230
+ h.emit(userMessage(3, 'user'))
231
+ h.emit(assistant(4, TURN, '好的'))
232
+ h.emit(turnEnd(5, TURN, { kind: 'completed' }))
233
+ assert.equal(h.timers.count(), 0)
234
+ })
235
+
236
+ test('S5b 用户插话且回合无可见输出:让路,不发静默补发', () => {
237
+ const h = createHarness()
238
+ h.emit(turnStart(0, TURN))
239
+ h.emit(userMessage(1))
240
+ h.emit(toolCall(2, TURN, 'read'))
241
+ h.emit(userMessage(3, 'user'))
242
+ h.emit(turnEnd(4, TURN, { kind: 'completed' }))
243
+ assert.equal(h.timers.count(), 0)
244
+ })
245
+
246
+ test('S6 可重试错误:发送继续文案', () => {
247
+ const h = createHarness()
248
+ h.emit(turnStart(0, TURN))
249
+ h.emit(userMessage(1))
250
+ h.emit(turnEnd(2, TURN, { kind: 'error', error: { code: 'UPSTREAM', message: 'bad gateway', status: 502 } }))
251
+ h.timers.flush()
252
+ assert.equal(h.sent.length, 1)
253
+ assert.match(textOf(h.sent[0]), /结果未知/)
254
+ })
255
+
256
+ test('S7 永久错误:不发送', () => {
257
+ const h = createHarness()
258
+ h.emit(turnStart(0, TURN))
259
+ h.emit(userMessage(1))
260
+ h.emit(turnEnd(2, TURN, { kind: 'error', error: { code: 'AUTH', message: 'invalid api key' } }))
261
+ assert.equal(h.timers.count(), 0)
262
+ })
263
+
264
+ test('S8 max-tokens:发送截断文案', () => {
265
+ const h = createHarness()
266
+ h.emit(turnStart(0, TURN))
267
+ h.emit(userMessage(1))
268
+ h.emit(turnEnd(2, TURN, { kind: 'max-tokens' }))
269
+ h.timers.flush()
270
+ assert.equal(h.sent.length, 1)
271
+ assert.match(textOf(h.sent[0]), /输出上限/)
272
+ })
273
+
274
+ test('S9 interrupted:发送重启文案', () => {
275
+ const h = createHarness()
276
+ h.emit(turnStart(0, TURN))
277
+ h.emit(userMessage(1))
278
+ h.emit(turnEnd(2, TURN, { kind: 'interrupted' }))
279
+ h.timers.flush()
280
+ assert.equal(h.sent.length, 1)
281
+ assert.match(textOf(h.sent[0]), /结果未知/)
282
+ })
283
+
284
+ test('S10 用户取消:不发送', () => {
285
+ const h = createHarness()
286
+ h.emit(turnStart(0, TURN))
287
+ h.emit(userMessage(1))
288
+ h.emit(turnEnd(2, TURN, { kind: 'aborted', reason: { kind: 'user' } }))
289
+ assert.equal(h.timers.count(), 0)
290
+ })
291
+
292
+ test('S11 宿主重启取消:按 interrupted 恢复', () => {
293
+ const h = createHarness()
294
+ h.emit(turnStart(0, TURN))
295
+ h.emit(userMessage(1))
296
+ h.emit(turnEnd(2, TURN, { kind: 'aborted', reason: { kind: 'disposed' } }))
297
+ h.timers.flush()
298
+ assert.equal(h.sent.length, 1)
299
+ })
300
+
301
+ test('S12 维护轮:不发送', () => {
302
+ const h = createHarness()
303
+ h.emit(turnStart(0, TURN, 'message-ops'))
304
+ h.emit(assistant(1, TURN, '压缩完成'))
305
+ h.emit(turnEnd(2, TURN, { kind: 'completed' }))
306
+ assert.equal(h.timers.count(), 0)
307
+ })
308
+
309
+ test('S13 宽限期内开启新回合:取消待发送', () => {
310
+ const h = createHarness()
311
+ h.emit(turnStart(0, TURN))
312
+ h.emit(userMessage(1))
313
+ h.emit(assistant(2, TURN, '没结案'))
314
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
315
+ assert.equal(h.timers.count(), 1)
316
+ h.emit(turnStart(4, TURN + 1))
317
+ assert.equal(h.timers.count(), 0)
318
+ })
319
+
320
+ test('S14 非用户发起回合:不发送结案提醒', () => {
321
+ const h = createHarness()
322
+ h.emit(turnStart(0, TURN))
323
+ h.emit(userMessage(1, 'goal'))
324
+ h.emit(assistant(2, TURN, '没结案'))
325
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
326
+ assert.equal(h.timers.count(), 0)
327
+ })
328
+
329
+ test('S15 无可见输出的回合:发送 silent 文案', () => {
330
+ const h = createHarness()
331
+ h.emit(turnStart(0, TURN))
332
+ h.emit(userMessage(1))
333
+ h.emit(turnEnd(2, TURN, { kind: 'completed' }))
334
+ h.timers.flush()
335
+ assert.equal(h.sent.length, 1)
336
+ assert.match(textOf(h.sent[0]), /没有可见输出/)
337
+ })
338
+
339
+ test('S17 present 之后又写文件:仍需提醒', () => {
340
+ const h = createHarness()
341
+ h.emit(turnStart(0, TURN))
342
+ h.emit(userMessage(1))
343
+ h.emit(toolCall(2, TURN, 'write'))
344
+ h.emit(presented(3, TURN))
345
+ h.emit(toolCall(4, TURN, 'edit'))
346
+ h.emit(assistant(5, TURN, '又改了'))
347
+ h.emit(turnEnd(6, TURN, { kind: 'completed' }))
348
+ assert.equal(h.timers.count(), 1)
349
+ h.timers.flush()
350
+ assert.equal(h.sent.length, 1)
351
+ })
352
+
353
+ test('S16 自己发出的消息不算用户接管', () => {
354
+ const h = createHarness()
355
+ h.emit(turnStart(0, TURN))
356
+ h.emit(userMessage(1))
357
+ h.emit(assistant(2, TURN, '没结案'))
358
+ h.emit(turnEnd(3, TURN, { kind: 'completed' }))
359
+ h.timers.flush()
360
+ const autoId = h.sent[0].id
361
+
362
+ h.advanceTo(h.now() + 60_000)
363
+ h.emit(turnStart(4, TURN + 1))
364
+ h.emit(userMessage(5, 'user', autoId))
365
+ h.emit(assistant(6, TURN + 1, '仍然没结案'))
366
+ h.emit(turnEnd(7, TURN + 1, { kind: 'completed' }))
367
+ h.timers.flush()
368
+ assert.equal(h.sent.length, 2)
369
+ })
370
+
371
+ test('卸载后不再响应事件', () => {
372
+ const h = createHarness()
373
+ h.engine.dispose()
374
+ h.emit(turnStart(0, TURN))
375
+ h.emit(userMessage(1))
376
+ h.emit(turnEnd(2, TURN, { kind: 'max-tokens' }))
377
+ assert.equal(h.timers.count(), 0)
378
+ })
379
+
380
+ test('离线时挂起,联网后补发', async () => {
381
+ let online = false
382
+ const h = createHarness({ probe: async () => online })
383
+ h.emit(turnStart(0, TURN))
384
+ h.emit(userMessage(1))
385
+ h.emit(turnEnd(2, TURN, { kind: 'max-tokens' }))
386
+ h.timers.flush()
387
+ await new Promise((resolve) => setImmediate(resolve))
388
+ assert.equal(h.sent.length, 0)
389
+ assert.equal(h.timers.count(), 1)
390
+
391
+ online = true
392
+ h.timers.flush()
393
+ await new Promise((resolve) => setImmediate(resolve))
394
+ assert.equal(h.sent.length, 1)
395
+ })
396
+
397
+ test('离线探测抛错时按在线处理', async () => {
398
+ const h = createHarness({
399
+ probe: async () => {
400
+ throw new Error('probe failed')
401
+ },
402
+ })
403
+ h.emit(turnStart(0, TURN))
404
+ h.emit(userMessage(1))
405
+ h.emit(turnEnd(2, TURN, { kind: 'max-tokens' }))
406
+ h.timers.flush()
407
+ await new Promise((resolve) => setImmediate(resolve))
408
+ assert.equal(h.sent.length, 1)
409
+ })
410
+
411
+ test('S18 插件补发的回合里用户插话一条:让路', () => {
412
+ const h = createHarness({ config: { maxConsecutive: 5 } })
413
+ h.runTurn(0, 1, '没结案', { human: true })
414
+ assert.equal(h.sent.length, 1)
415
+ h.advanceTo(h.now() + 60_000)
416
+ // 第二回合由 auto-1 发起,真人随后插了一条
417
+ const autoId = h.sent.at(-1).id
418
+ h.emit(turnStart(10, 2))
419
+ h.emit(userMessage(11, 'user', autoId))
420
+ h.emit(userMessage(12, 'user', 'm-12'))
421
+ h.emit(assistant(13, 2, '没结案'))
422
+ h.emit(turnEnd(14, 2, { kind: 'completed' }))
423
+ h.timers.flush()
424
+ assert.equal(h.sent.length, 1, '插件回合内的真人插话必须让路')
425
+ })
426
+
427
+ test('S19 空回合开关关闭但有有效写入:仍要求 present', () => {
428
+ const h = createHarness({ config: { resumeSilentTurns: false } })
429
+ h.emit(turnStart(0, TURN))
430
+ h.emit(userMessage(1))
431
+ h.emit(toolCall(2, TURN, 'write'))
432
+ h.emit(turnEnd(4, TURN, { kind: 'completed' }))
433
+ h.timers.flush()
434
+ assert.equal(h.sent.length, 1, '文件已改就不能被空回合开关跳过')
435
+ assert.match(textOf(h.sent[0]), /TASK_DONE/)
436
+ })
@@ -0,0 +1,81 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ isTransientFailure,
6
+ parseFailureFacts,
7
+ } from '../src/failure.mjs'
8
+
9
+ const facts = (code, message, status) =>
10
+ status === undefined ? { code, message } : { code, message, status }
11
+
12
+ test('parseFailureFacts 只接受可解释的失败载荷', () => {
13
+ assert.deepEqual(parseFailureFacts({ code: 'UPSTREAM', message: 'boom', status: 502 }), {
14
+ code: 'UPSTREAM',
15
+ message: 'boom',
16
+ status: 502,
17
+ })
18
+ assert.deepEqual(parseFailureFacts({ message: '只有描述' }), { code: 'UNKNOWN', message: '只有描述' })
19
+ assert.equal(parseFailureFacts(undefined), undefined)
20
+ assert.equal(parseFailureFacts(null), undefined)
21
+ assert.equal(parseFailureFacts('boom'), undefined)
22
+ assert.equal(parseFailureFacts({}), undefined)
23
+ })
24
+
25
+ test('鉴权与权限失败属永久错误', () => {
26
+ assert.equal(isTransientFailure(facts('UNAUTHORIZED', 'invalid api key')), false)
27
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'forbidden', 403)), false)
28
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'something', 401)), false)
29
+ })
30
+
31
+ test('余额与配额耗尽属永久错误', () => {
32
+ assert.equal(isTransientFailure(facts('BILLING', 'insufficient balance')), false)
33
+ assert.equal(isTransientFailure(facts('QUOTA', 'insufficient quota for this account')), false)
34
+ })
35
+
36
+ test('模型不存在与上下文超限属永久错误', () => {
37
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'model not found: foo')), false)
38
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'context length exceeded')), false)
39
+ })
40
+
41
+ test('网络、超时、5xx、429 属临时错误', () => {
42
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'bad gateway', 502)), true)
43
+ assert.equal(isTransientFailure(facts('RATE_LIMIT', 'rate limit exceeded', 429)), true)
44
+ assert.equal(isTransientFailure(facts('TIMEOUT', 'request timed out')), true)
45
+ })
46
+
47
+ test('MALFORMED_RESPONSE 虽含关键词仍按临时处理', () => {
48
+ assert.equal(isTransientFailure(facts('MALFORMED_RESPONSE', 'tool echoed {api_key}')), true)
49
+ })
50
+
51
+ test('宿主把状态并进 message 时仍识别 401/403', () => {
52
+ assert.equal(isTransientFailure(facts('AUTH', '401 {"error":{"status":401}}')), false)
53
+ assert.equal(isTransientFailure(facts('UNKNOWN', 'upstream replied 403 forbidden')), false)
54
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'bad gateway 502')), true)
55
+ })
56
+
57
+ test('AUTH 码单独出现即为永久', () => {
58
+ assert.equal(isTransientFailure(facts('AUTH', 'credentials rejected')), false)
59
+ })
60
+
61
+ test('用户自定义片段优先于内置判定', () => {
62
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'invalid api key'), 'invalid api key'), true)
63
+ assert.equal(isTransientFailure(facts('UPSTREAM', 'bad gateway', 502), 'bad gateway'), true)
64
+ })
65
+
66
+ test('规范码判永久,不看中性描述', () => {
67
+ for (const code of ['QUOTA', 'ACCOUNT_QUOTA', 'NO_ADAPTER', 'INVALID_ARGS', 'AUTH', 'CONTEXT_WINDOW_EXCEEDED']) {
68
+ assert.equal(isTransientFailure({ code, message: 'the request failed' }), false, `${code} 应判永久`)
69
+ }
70
+ })
71
+
72
+ test('中性描述的额度原文不被当临时错误', () => {
73
+ const samples = [
74
+ 'Resource has been exhausted (e.g. check quota).',
75
+ 'Your credit balance is too low to access the Anthropic API',
76
+ ]
77
+ for (const message of samples) {
78
+ assert.equal(isTransientFailure({ code: 'QUOTA', message }), false, message)
79
+ assert.equal(isTransientFailure({ code: 'UNKNOWN', message }), false, `无规范码时也要按文本拦下: ${message}`)
80
+ }
81
+ })
@@ -0,0 +1,85 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ createConnectivityProbe,
6
+ hasExternalInterface,
7
+ usesExplicitProxy,
8
+ } from '../src/offline.mjs'
9
+
10
+ const interfacesOf = (entries) => () => entries
11
+
12
+ test('无外部网卡时判定离线', () => {
13
+ assert.equal(hasExternalInterface(interfacesOf({})), false)
14
+ assert.equal(hasExternalInterface(interfacesOf({ lo: [{ internal: true, family: 'IPv4' }] })), false)
15
+ assert.equal(
16
+ hasExternalInterface(interfacesOf({ eth0: [{ internal: false, family: 'IPv4' }] })),
17
+ true,
18
+ )
19
+ })
20
+
21
+ test('读不到网卡信息时放行,不误判离线', () => {
22
+ assert.equal(
23
+ hasExternalInterface(() => {
24
+ throw new Error('boom')
25
+ }),
26
+ true,
27
+ )
28
+ })
29
+
30
+ test('显式代理存在时不依赖本地 DNS', () => {
31
+ assert.equal(usesExplicitProxy({ HTTPS_PROXY: 'http://127.0.0.1:7890' }), true)
32
+ assert.equal(usesExplicitProxy({ https_proxy: 'http://127.0.0.1:7890' }), true)
33
+ assert.equal(usesExplicitProxy({ HTTPS_PROXY: ' ' }), false)
34
+ assert.equal(usesExplicitProxy({}), false)
35
+ })
36
+
37
+ test('任一探测主机可解析即在线', async () => {
38
+ const probe = createConnectivityProbe({
39
+ interfaces: interfacesOf({ eth0: [{ internal: false }] }),
40
+ env: {},
41
+ hosts: ['a.invalid', 'b.invalid'],
42
+ lookup: async (host) => {
43
+ if (host === 'b.invalid') return { address: '127.0.0.1' }
44
+ throw new Error('ENOTFOUND')
45
+ },
46
+ })
47
+ assert.equal(await probe(), true)
48
+ })
49
+
50
+ test('全部探测主机不可解析即离线', async () => {
51
+ const probe = createConnectivityProbe({
52
+ interfaces: interfacesOf({ eth0: [{ internal: false }] }),
53
+ env: {},
54
+ hosts: ['a.invalid', 'b.invalid'],
55
+ lookup: async () => {
56
+ throw new Error('ENOTFOUND')
57
+ },
58
+ })
59
+ assert.equal(await probe(), false)
60
+ })
61
+
62
+ test('探测失败不 reject,一律折算为在线或离线', async () => {
63
+ const probe = createConnectivityProbe({
64
+ interfaces: interfacesOf({ eth0: [{ internal: false }] }),
65
+ env: {},
66
+ hosts: ['a.invalid'],
67
+ lookup: () => Promise.reject(new Error('boom')),
68
+ })
69
+ assert.equal(await probe(), false)
70
+ })
71
+
72
+ test('无外部网卡时短路,不触发 DNS', async () => {
73
+ let called = false
74
+ const probe = createConnectivityProbe({
75
+ interfaces: interfacesOf({ lo: [{ internal: true }] }),
76
+ env: {},
77
+ hosts: ['a.invalid'],
78
+ lookup: async () => {
79
+ called = true
80
+ return { address: '127.0.0.1' }
81
+ },
82
+ })
83
+ assert.equal(await probe(), false)
84
+ assert.equal(called, false)
85
+ })
@@ -0,0 +1,32 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { DEFAULTS } from '../src/config.mjs'
5
+ import { PROMPT_TEXT, SECTION_NAME, buildSection } from '../src/index.js'
6
+
7
+ /** 宿主文件引用段的真实位置,取自 dsh-system-prompt 的 SECTION_ORDERS。 */
8
+ const FILE_REFERENCE_ORDER = 900
9
+
10
+ const systemPrompt = { getSectionOrder: () => FILE_REFERENCE_ORDER }
11
+
12
+ test('结案段落排在文件引用建议之后', () => {
13
+ const section = buildSection(systemPrompt)
14
+ assert.equal(section.name, SECTION_NAME)
15
+ assert.ok(section.order > FILE_REFERENCE_ORDER, '结案规则必须压在被覆盖的建议之后')
16
+ })
17
+
18
+ test('结案段落保留显式提交要求', () => {
19
+ for (const marker of ['TASK_DONE', 'TASK_BLOCKED: 原因', 'present']) {
20
+ assert.ok(PROMPT_TEXT.includes(marker), `缺少 ${marker}`)
21
+ }
22
+ })
23
+
24
+ test('结案段落声明 present 时机不受文件引用建议约束', () => {
25
+ assert.ok(PROMPT_TEXT.includes('present 的时机以本段为准'))
26
+ })
27
+
28
+ test('默认结案提醒与新标记一致', () => {
29
+ for (const marker of ['TASK_DONE', 'TASK_BLOCKED: 原因']) {
30
+ assert.ok(DEFAULTS.closureText.includes(marker), `缺少 ${marker}`)
31
+ }
32
+ })
@@ -0,0 +1,57 @@
1
+ // 宿主闸门:插件路由必须回问 connection,未认证请求不得进业务面
2
+ import { test } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+
5
+ import { rejectUnadmitted, withHostGate } from '../src/route-gate.mjs'
6
+
7
+ function ctxWith(connection) {
8
+ return { get: (name) => (name === 'connection' ? connection : undefined) }
9
+ }
10
+
11
+ function makeRes() {
12
+ const res = { status: null, body: null, headers: null, ended: false }
13
+ res.writeHead = (status, headers) => {
14
+ res.status = status
15
+ res.headers = headers
16
+ }
17
+ res.end = (body) => {
18
+ res.ended = true
19
+ res.body = body
20
+ }
21
+ return res
22
+ }
23
+
24
+ test('G1 无 connection 服务时放行', () => {
25
+ const res = makeRes()
26
+ assert.equal(rejectUnadmitted(ctxWith(undefined), { headers: {} }, res), false)
27
+ assert.equal(res.ended, false)
28
+ })
29
+
30
+ test('G2 宿主拒绝时按状态码回纯文本', () => {
31
+ for (const [status, text] of [[401, 'unauthorized'], [403, 'forbidden']]) {
32
+ const res = makeRes()
33
+ assert.equal(rejectUnadmitted(ctxWith({ requestRejection: () => status }), { headers: {} }, res), true)
34
+ assert.equal(res.status, status)
35
+ assert.equal(res.body, text)
36
+ }
37
+ })
38
+
39
+ test('G3 闸门抛错按拒绝处理,不把故障当认证通过', () => {
40
+ const res = makeRes()
41
+ const ctx = ctxWith({ requestRejection: () => { throw new Error('boom') } })
42
+ assert.equal(rejectUnadmitted(ctx, { headers: {} }, res), true)
43
+ assert.equal(res.status, 403)
44
+ })
45
+
46
+ test('G4 withHostGate 拒绝时业务不执行,放行时透传', async () => {
47
+ let called = 0
48
+ const blocked = withHostGate(ctxWith({ requestRejection: () => 401 }), async () => { called += 1 })
49
+ await blocked({ headers: {} }, makeRes())
50
+ assert.equal(called, 0)
51
+ const seen = []
52
+ const pass = withHostGate(ctxWith(undefined), async (req, res) => { seen.push(req, res) })
53
+ const req = { headers: {} }
54
+ const res = makeRes()
55
+ await pass(req, res)
56
+ assert.deepEqual(seen, [req, res])
57
+ })