@mzzsfy/dsh-keep-working 0.1.0 → 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.
@@ -1,436 +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, '结论\n(TASK_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, '结论\n(TASK_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
- })
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
+ })