switchroom 0.14.21 → 0.14.23

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 (43) hide show
  1. package/dist/agent-scheduler/index.js +0 -1
  2. package/dist/auth-broker/index.js +0 -1
  3. package/dist/cli/notion-write-pretool.mjs +0 -1
  4. package/dist/cli/switchroom.js +14 -6
  5. package/dist/host-control/main.js +0 -1
  6. package/dist/vault/approvals/kernel-server.js +0 -1
  7. package/dist/vault/broker/server.js +0 -1
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +11 -24
  10. package/profiles/_shared/telegram-style.md.hbs +2 -2
  11. package/profiles/default/CLAUDE.md.hbs +4 -1
  12. package/skills/switchroom-runtime/SKILL.md +6 -16
  13. package/telegram-plugin/agent-dir.ts +15 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +788 -513
  15. package/telegram-plugin/gateway/gateway.ts +216 -61
  16. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  17. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  18. package/telegram-plugin/registry/turns-schema.ts +138 -33
  19. package/telegram-plugin/stream-reply-handler.ts +1 -11
  20. package/telegram-plugin/subagent-watcher.ts +79 -5
  21. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  22. package/telegram-plugin/tests/e2e.test.ts +2 -77
  23. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  24. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  25. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  26. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  27. package/telegram-plugin/tests/races.test.ts +0 -26
  28. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  29. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  30. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  31. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  32. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  33. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  34. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  35. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
  36. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
  37. package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
  38. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  39. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  40. package/telegram-plugin/tool-activity-summary.ts +55 -0
  41. package/telegram-plugin/uat/driver.ts +3 -1
  42. package/telegram-plugin/handoff-continuity.ts +0 -206
  43. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -7,9 +7,6 @@
7
7
  * server.ts directly.
8
8
  */
9
9
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
10
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11
- import { tmpdir } from 'node:os'
12
- import { join } from 'node:path'
13
10
 
14
11
  import {
15
12
  parseQueuePrefix,
@@ -17,11 +14,6 @@ import {
17
14
  formatPriorAssistantPreview,
18
15
  buildChannelMetaAttributes,
19
16
  } from '../steering.js'
20
- import {
21
- consumeHandoffTopic,
22
- formatHandoffLine,
23
- HANDOFF_TOPIC_FILENAME,
24
- } from '../handoff-continuity.js'
25
17
 
26
18
  // ---- harness (copy of e2e.test.ts's — intentional; tests stay isolated) ----
27
19
 
@@ -213,24 +205,6 @@ describe('Race: reply tool claims PTY preview suppression gate', () => {
213
205
  })
214
206
  })
215
207
 
216
- describe('Race: handoff topic consumed once, second reply no-ops', () => {
217
- let tmp: string
218
- beforeEach(() => {
219
- tmp = mkdtempSync(join(tmpdir(), 'handoff-race-'))
220
- })
221
- afterEach(() => {
222
- rmSync(tmp, { recursive: true, force: true })
223
- })
224
-
225
- it('topic consumed on first reply; second reply sees null', () => {
226
- writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'topic\n', 'utf8')
227
- const first = consumeHandoffTopic(tmp)
228
- expect(first).toBe('topic')
229
- const second = consumeHandoffTopic(tmp)
230
- expect(second).toBeNull()
231
- })
232
- })
233
-
234
208
  describe('Race: activeTurnStartedAt cleanup on every exit path', () => {
235
209
  // Parameterized test — exercises every code path that deletes a
236
210
  // status controller in server.ts and asserts the Map is empty after.
@@ -17,10 +17,20 @@ import {
17
17
  recordTurnStart,
18
18
  recordTurnEnd,
19
19
  findOrphanedTurns,
20
- markOrphanedAsRestarted,
21
- findMostRecentInterruptedTurn,
20
+ markOrphanedWithTimeoutClassification,
21
+ findLatestTurnIfInterrupted,
22
22
  } from '../registry/turns-schema.js'
23
23
 
24
+ // Convenience: the boot reaper with no live hang marker — every open turn
25
+ // is a clean 'restart' interrupt. Mirrors the gateway's between-turns boot.
26
+ function reapAsRestart(db: Parameters<typeof findOrphanedTurns>[0]) {
27
+ return markOrphanedWithTimeoutClassification(db, {
28
+ markerTurnKey: null,
29
+ markerAgeMs: null,
30
+ hangThresholdMs: 300_000,
31
+ })
32
+ }
33
+
24
34
  // ---------------------------------------------------------------------------
25
35
  // Helpers
26
36
  // ---------------------------------------------------------------------------
@@ -280,17 +290,18 @@ describe('findOrphanedTurns', () => {
280
290
  })
281
291
 
282
292
  // ---------------------------------------------------------------------------
283
- // markOrphanedAsRestarted
293
+ // markOrphanedWithTimeoutClassification — no-hang-marker (clean restart) path
284
294
  // ---------------------------------------------------------------------------
285
295
 
286
- describe('markOrphanedAsRestarted', () => {
296
+ describe('markOrphanedWithTimeoutClassification (restart path)', () => {
287
297
  it('stamps all open turns with ended_via=restart and non-null ended_at', () => {
288
298
  const db = openTurnsDbInMemory()
289
299
  recordTurnStart(db, { turnKey: '555:1', chatId: '555' })
290
300
  recordTurnStart(db, { turnKey: '555:2', chatId: '555' })
291
301
 
292
- const count = markOrphanedAsRestarted(db)
293
- expect(count).toBe(2)
302
+ const res = reapAsRestart(db)
303
+ expect(res.reaped).toBe(2)
304
+ expect(res.timeoutTurnKey).toBeNull()
294
305
 
295
306
  const rows = db.prepare(
296
307
  "SELECT * FROM turns WHERE ended_via = 'restart'",
@@ -309,7 +320,7 @@ describe('markOrphanedAsRestarted', () => {
309
320
  recordTurnEnd(db, { turnKey: '555:3', endedVia: 'stop' })
310
321
  recordTurnStart(db, { turnKey: '555:4', chatId: '555' })
311
322
 
312
- markOrphanedAsRestarted(db)
323
+ reapAsRestart(db)
313
324
 
314
325
  const closed = db.prepare(
315
326
  "SELECT ended_via FROM turns WHERE turn_key = '555:3'",
@@ -323,41 +334,99 @@ describe('markOrphanedAsRestarted', () => {
323
334
  db.close()
324
335
  })
325
336
 
326
- it('after markOrphanedAsRestarted, findOrphanedTurns returns empty', () => {
337
+ it('after reaping, findOrphanedTurns returns empty', () => {
327
338
  const db = openTurnsDbInMemory()
328
339
  recordTurnStart(db, { turnKey: '666:1', chatId: '666' })
329
340
  recordTurnStart(db, { turnKey: '666:2', chatId: '666' })
330
341
 
331
- markOrphanedAsRestarted(db)
342
+ reapAsRestart(db)
332
343
 
333
344
  expect(findOrphanedTurns(db, '666')).toHaveLength(0)
334
345
  db.close()
335
346
  })
336
347
 
337
- it('returns 0 when there are no open turns', () => {
348
+ it('reaps 0 when there are no open turns', () => {
338
349
  const db = openTurnsDbInMemory()
339
350
  recordTurnStart(db, { turnKey: '777:1', chatId: '777' })
340
351
  recordTurnEnd(db, { turnKey: '777:1', endedVia: 'stop' })
341
352
 
342
- expect(markOrphanedAsRestarted(db)).toBe(0)
353
+ expect(reapAsRestart(db).reaped).toBe(0)
343
354
  db.close()
344
355
  })
345
356
 
346
- it('is safe to call on an empty DB (returns 0, no error)', () => {
357
+ it('is safe to call on an empty DB (reaps 0, no error)', () => {
347
358
  const db = openTurnsDbInMemory()
348
- expect(markOrphanedAsRestarted(db)).toBe(0)
359
+ expect(reapAsRestart(db).reaped).toBe(0)
349
360
  db.close()
350
361
  })
351
362
  })
352
363
 
353
364
  // ---------------------------------------------------------------------------
354
- // findMostRecentInterruptedTurn
365
+ // markOrphanedWithTimeoutClassification — hang-marker (timeout) path
355
366
  // ---------------------------------------------------------------------------
356
367
 
357
- describe('findMostRecentInterruptedTurn', () => {
368
+ describe('markOrphanedWithTimeoutClassification (timeout path)', () => {
369
+ it('stamps the marker turn timeout when its marker is older than the threshold', () => {
370
+ const db = openTurnsDbInMemory()
371
+ recordTurnStart(db, { turnKey: 'hang:1', chatId: 'h' })
372
+ recordTurnStart(db, { turnKey: 'live:2', chatId: 'h' })
373
+
374
+ const res = markOrphanedWithTimeoutClassification(db, {
375
+ markerTurnKey: 'hang:1',
376
+ markerAgeMs: 600_000, // 10 min > 5 min threshold
377
+ hangThresholdMs: 300_000,
378
+ reasonSnapshot: JSON.stringify({ idleMs: 600_000 }),
379
+ })
380
+
381
+ expect(res.timeoutTurnKey).toBe('hang:1')
382
+ expect(res.reaped).toBe(2)
383
+
384
+ const hang = db.prepare("SELECT * FROM turns WHERE turn_key = 'hang:1'").get() as Record<string, unknown>
385
+ expect(hang['ended_via']).toBe('timeout')
386
+ expect(hang['interrupt_reason']).toBe(JSON.stringify({ idleMs: 600_000 }))
387
+ // The other open turn is a clean restart.
388
+ const live = db.prepare("SELECT * FROM turns WHERE turn_key = 'live:2'").get() as Record<string, unknown>
389
+ expect(live['ended_via']).toBe('restart')
390
+ db.close()
391
+ })
392
+
393
+ it('classifies the marker turn as restart when its marker is younger than the threshold', () => {
394
+ const db = openTurnsDbInMemory()
395
+ recordTurnStart(db, { turnKey: 'fresh:1', chatId: 'h' })
396
+
397
+ const res = markOrphanedWithTimeoutClassification(db, {
398
+ markerTurnKey: 'fresh:1',
399
+ markerAgeMs: 5_000, // 5s — was making progress
400
+ hangThresholdMs: 300_000,
401
+ })
402
+
403
+ expect(res.timeoutTurnKey).toBeNull()
404
+ const row = db.prepare("SELECT ended_via FROM turns WHERE turn_key = 'fresh:1'").get() as Record<string, unknown>
405
+ expect(row['ended_via']).toBe('restart')
406
+ db.close()
407
+ })
408
+
409
+ it('does not classify timeout when no marker turn key is present (between-turns boot)', () => {
410
+ const db = openTurnsDbInMemory()
411
+ recordTurnStart(db, { turnKey: 'x:1', chatId: 'h' })
412
+ const res = markOrphanedWithTimeoutClassification(db, {
413
+ markerTurnKey: null,
414
+ markerAgeMs: 999_999,
415
+ hangThresholdMs: 300_000,
416
+ })
417
+ expect(res.timeoutTurnKey).toBeNull()
418
+ db.close()
419
+ })
420
+ })
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // findLatestTurnIfInterrupted — keys on the LATEST turn only
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe('findLatestTurnIfInterrupted', () => {
358
427
  it('returns null when no turns exist', () => {
359
428
  const db = openTurnsDbInMemory()
360
- expect(findMostRecentInterruptedTurn(db)).toBeNull()
429
+ expect(findLatestTurnIfInterrupted(db)).toBeNull()
361
430
  db.close()
362
431
  })
363
432
 
@@ -365,14 +434,14 @@ describe('findMostRecentInterruptedTurn', () => {
365
434
  const db = openTurnsDbInMemory()
366
435
  recordTurnStart(db, { turnKey: '888:1', chatId: '888' })
367
436
  recordTurnEnd(db, { turnKey: '888:1', endedVia: 'stop' })
368
- expect(findMostRecentInterruptedTurn(db)).toBeNull()
437
+ expect(findLatestTurnIfInterrupted(db)).toBeNull()
369
438
  db.close()
370
439
  })
371
440
 
372
441
  it('returns an open turn (ended_at IS NULL) as interrupted', () => {
373
442
  const db = openTurnsDbInMemory()
374
443
  recordTurnStart(db, { turnKey: '999:1', chatId: '999', lastUserMsgId: 'msg-1' })
375
- const t = findMostRecentInterruptedTurn(db)
444
+ const t = findLatestTurnIfInterrupted(db)
376
445
  expect(t).not.toBeNull()
377
446
  expect(t!.turn_key).toBe('999:1')
378
447
  expect(t!.last_user_msg_id).toBe('msg-1')
@@ -383,7 +452,7 @@ describe('findMostRecentInterruptedTurn', () => {
383
452
  const db = openTurnsDbInMemory()
384
453
  recordTurnStart(db, { turnKey: 'aaa:1', chatId: 'aaa' })
385
454
  recordTurnEnd(db, { turnKey: 'aaa:1', endedVia: 'sigterm' })
386
- const t = findMostRecentInterruptedTurn(db)
455
+ const t = findLatestTurnIfInterrupted(db)
387
456
  expect(t).not.toBeNull()
388
457
  expect(t!.ended_via).toBe('sigterm')
389
458
  db.close()
@@ -393,30 +462,40 @@ describe('findMostRecentInterruptedTurn', () => {
393
462
  const db = openTurnsDbInMemory()
394
463
  recordTurnStart(db, { turnKey: 'bbb:1', chatId: 'bbb' })
395
464
  recordTurnEnd(db, { turnKey: 'bbb:1', endedVia: 'restart' })
396
- const t = findMostRecentInterruptedTurn(db)
465
+ const t = findLatestTurnIfInterrupted(db)
397
466
  expect(t).not.toBeNull()
398
467
  expect(t!.ended_via).toBe('restart')
399
468
  db.close()
400
469
  })
401
470
 
402
- it('picks the most-recently-started across multiple interrupted turns', () => {
471
+ it('returns a timeout-stamped turn as interrupted', () => {
472
+ const db = openTurnsDbInMemory()
473
+ recordTurnStart(db, { turnKey: 'tmo:1', chatId: 'tmo' })
474
+ recordTurnEnd(db, { turnKey: 'tmo:1', endedVia: 'timeout' })
475
+ const t = findLatestTurnIfInterrupted(db)
476
+ expect(t).not.toBeNull()
477
+ expect(t!.ended_via).toBe('timeout')
478
+ db.close()
479
+ })
480
+
481
+ it('picks the most-recently-started turn (latest), not an older interrupted one', () => {
403
482
  const db = openTurnsDbInMemory()
404
483
  recordTurnStart(db, { turnKey: 'ccc:1', chatId: 'ccc' })
405
- // Different started_at by waiting one ms; bun:sqlite stores the
406
- // recordTurnStart call's Date.now() so we use raw insert below to be
407
- // deterministic.
408
484
  db.exec(`UPDATE turns SET started_at = 1000 WHERE turn_key = 'ccc:1'`)
409
485
  recordTurnStart(db, { turnKey: 'ccc:2', chatId: 'ccc' })
410
486
  db.exec(`UPDATE turns SET started_at = 2000 WHERE turn_key = 'ccc:2'`)
411
487
  recordTurnEnd(db, { turnKey: 'ccc:1', endedVia: 'restart' })
412
488
  recordTurnEnd(db, { turnKey: 'ccc:2', endedVia: 'sigterm' })
413
- const t = findMostRecentInterruptedTurn(db)
489
+ const t = findLatestTurnIfInterrupted(db)
414
490
  expect(t).not.toBeNull()
415
491
  expect(t!.turn_key).toBe('ccc:2')
416
492
  db.close()
417
493
  })
418
494
 
419
- it('skips a clean stop and picks an older interrupted turn', () => {
495
+ it('a clean latest turn SHADOWS an older interrupted one (returns null)', () => {
496
+ // This is the inverse of the old findMostRecentInterruptedTurn bug: a
497
+ // completed resume (latest turn 'stop') must not resurface the stale
498
+ // interrupted turn on the next restart.
420
499
  const db = openTurnsDbInMemory()
421
500
  recordTurnStart(db, { turnKey: 'ddd:1', chatId: 'ddd' })
422
501
  db.exec(`UPDATE turns SET started_at = 1000 WHERE turn_key = 'ddd:1'`)
@@ -424,9 +503,7 @@ describe('findMostRecentInterruptedTurn', () => {
424
503
  db.exec(`UPDATE turns SET started_at = 2000 WHERE turn_key = 'ddd:2'`)
425
504
  recordTurnEnd(db, { turnKey: 'ddd:1', endedVia: 'sigterm' })
426
505
  recordTurnEnd(db, { turnKey: 'ddd:2', endedVia: 'stop' })
427
- const t = findMostRecentInterruptedTurn(db)
428
- expect(t).not.toBeNull()
429
- expect(t!.turn_key).toBe('ddd:1')
506
+ expect(findLatestTurnIfInterrupted(db)).toBeNull()
430
507
  db.close()
431
508
  })
432
509
  })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Unit tests for telegram-plugin/gateway/resume-inbound-builder.ts
3
+ *
4
+ * Pure builders — no SQLite, no gateway. They run under bun test alongside
5
+ * the other telegram-plugin tests. The contract under test:
6
+ *
7
+ * - humanizeElapsed bucketing (moments / min / h / days, plus the
8
+ * negative/NaN guard).
9
+ * - buildResumeInterruptedInbound → source='resume_interrupted', resume
10
+ * framing, dedup anchor meta.resume_turn_key, thread routing.
11
+ * - buildResumeWatchdogReportInbound → source='resume_watchdog_timeout',
12
+ * report (not resume) framing, idle_ms passthrough.
13
+ * - selectResumeBuilder policy table.
14
+ */
15
+
16
+ import { describe, it, expect } from 'bun:test'
17
+ import {
18
+ humanizeElapsed,
19
+ buildResumeInterruptedInbound,
20
+ buildResumeWatchdogReportInbound,
21
+ selectResumeBuilder,
22
+ } from '../gateway/resume-inbound-builder.js'
23
+ import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
24
+
25
+ function makeTurn(overrides: Partial<Turn> = {}): Turn {
26
+ return {
27
+ turn_key: '12345:11',
28
+ chat_id: '12345',
29
+ thread_id: null,
30
+ started_at: 1_000_000,
31
+ ended_at: null,
32
+ ended_via: 'restart',
33
+ last_assistant_msg_id: null,
34
+ last_assistant_done: null,
35
+ last_user_msg_id: null,
36
+ user_prompt_preview: null,
37
+ assistant_reply_preview: null,
38
+ tool_call_count: null,
39
+ interrupt_reason: null,
40
+ created_at: 1_000_000,
41
+ updated_at: 1_000_000,
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ describe('humanizeElapsed', () => {
47
+ it('returns "moments" under 45s', () => {
48
+ expect(humanizeElapsed(0)).toBe('moments')
49
+ expect(humanizeElapsed(44_000)).toBe('moments')
50
+ })
51
+
52
+ it('buckets minutes under an hour', () => {
53
+ expect(humanizeElapsed(60_000)).toBe('~1 min')
54
+ expect(humanizeElapsed(5 * 60_000)).toBe('~5 min')
55
+ expect(humanizeElapsed(59 * 60_000)).toBe('~59 min')
56
+ })
57
+
58
+ it('buckets hours under a day', () => {
59
+ expect(humanizeElapsed(60 * 60_000)).toBe('~1h')
60
+ expect(humanizeElapsed(3 * 60 * 60_000)).toBe('~3h')
61
+ })
62
+
63
+ it('buckets days at/over 24h with singular/plural', () => {
64
+ expect(humanizeElapsed(24 * 60 * 60_000)).toBe('~1 day')
65
+ expect(humanizeElapsed(50 * 60 * 60_000)).toBe('~2 days')
66
+ })
67
+
68
+ it('guards against negative / non-finite input', () => {
69
+ expect(humanizeElapsed(-5)).toBe('an unknown amount of time')
70
+ expect(humanizeElapsed(NaN)).toBe('an unknown amount of time')
71
+ expect(humanizeElapsed(Infinity)).toBe('an unknown amount of time')
72
+ })
73
+ })
74
+
75
+ describe('buildResumeInterruptedInbound', () => {
76
+ it('sets the resume_interrupted source and dedup anchor', () => {
77
+ const turn = makeTurn({ turn_key: 'abc:7', ended_via: 'sigterm' })
78
+ const msg = buildResumeInterruptedInbound({ turn, nowMs: turn.started_at + 3 * 60 * 60_000 })
79
+ expect(msg.type).toBe('inbound')
80
+ expect(msg.meta.source).toBe('resume_interrupted')
81
+ expect(msg.meta.resume_turn_key).toBe('abc:7')
82
+ expect(msg.meta.interrupted_via).toBe('sigterm')
83
+ expect(msg.user).toBe('switchroom')
84
+ expect(msg.userId).toBe(0)
85
+ })
86
+
87
+ it('frames the elapsed time in the body and tells the model to resume, not ask', () => {
88
+ const turn = makeTurn()
89
+ const msg = buildResumeInterruptedInbound({ turn, nowMs: turn.started_at + 3 * 60 * 60_000 })
90
+ expect(msg.text).toContain('~3h')
91
+ expect(msg.text.toLowerCase()).toContain('interrupted')
92
+ expect(msg.text.toLowerCase()).toContain('do')
93
+ expect(msg.text).toContain('not ask whether to resume')
94
+ })
95
+
96
+ it('defaults interrupted_via to restart when ended_via is null', () => {
97
+ const turn = makeTurn({ ended_via: null })
98
+ const msg = buildResumeInterruptedInbound({ turn })
99
+ expect(msg.meta.interrupted_via).toBe('restart')
100
+ })
101
+
102
+ it('includes the prompt preview when present and carries original_prompt meta', () => {
103
+ const turn = makeTurn({ user_prompt_preview: 'refactor the auth module' })
104
+ const msg = buildResumeInterruptedInbound({ turn })
105
+ expect(msg.text).toContain('refactor the auth module')
106
+ expect(msg.meta.original_prompt).toBe('refactor the auth module')
107
+ })
108
+
109
+ it('truncates a long prompt preview in the body', () => {
110
+ const long = 'x'.repeat(300)
111
+ const turn = makeTurn({ user_prompt_preview: long })
112
+ const msg = buildResumeInterruptedInbound({ turn })
113
+ expect(msg.text).toContain('…')
114
+ expect(msg.text).not.toContain('x'.repeat(200))
115
+ })
116
+
117
+ it('routes to the forum thread when thread_id is numeric', () => {
118
+ const turn = makeTurn({ thread_id: '99' })
119
+ const msg = buildResumeInterruptedInbound({ turn })
120
+ expect(msg.threadId).toBe(99)
121
+ })
122
+
123
+ it('omits threadId for a non-forum (null thread_id) chat', () => {
124
+ const msg = buildResumeInterruptedInbound({ turn: makeTurn({ thread_id: null }) })
125
+ expect(msg.threadId).toBeUndefined()
126
+ })
127
+ })
128
+
129
+ describe('buildResumeWatchdogReportInbound', () => {
130
+ it('sets the resume_watchdog_timeout source and idle_ms passthrough', () => {
131
+ const turn = makeTurn({ ended_via: 'timeout' })
132
+ const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
133
+ expect(msg.meta.source).toBe('resume_watchdog_timeout')
134
+ expect(msg.meta.interrupted_via).toBe('timeout')
135
+ expect(msg.meta.idle_ms).toBe('300000')
136
+ })
137
+
138
+ it('reports the hang honestly and asks rather than resuming', () => {
139
+ const turn = makeTurn({ ended_via: 'timeout' })
140
+ const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
141
+ expect(msg.text.toLowerCase()).toContain('hang-watchdog')
142
+ expect(msg.text).toContain('no observable progress')
143
+ expect(msg.text).toContain('Do NOT silently resume')
144
+ expect(msg.text.toLowerCase()).toContain('take a different angle')
145
+ })
146
+
147
+ it('mentions tool-call count when the turn ran tools before stalling', () => {
148
+ const turn = makeTurn({ ended_via: 'timeout', tool_call_count: 4 })
149
+ const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
150
+ expect(msg.text).toContain('4 tool calls')
151
+ expect(msg.meta.tool_call_count).toBe('4')
152
+ })
153
+
154
+ it('singularizes a single tool call', () => {
155
+ const turn = makeTurn({ ended_via: 'timeout', tool_call_count: 1 })
156
+ const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
157
+ expect(msg.text).toContain('1 tool call')
158
+ expect(msg.text).not.toContain('1 tool calls')
159
+ })
160
+
161
+ it('omits the tool clause when no tools ran', () => {
162
+ const turn = makeTurn({ ended_via: 'timeout', tool_call_count: 0 })
163
+ const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
164
+ expect(msg.text).not.toContain('tool call')
165
+ })
166
+ })
167
+
168
+ describe('selectResumeBuilder', () => {
169
+ const cases: Array<[TurnEndedVia | null, 'resume' | 'report' | null]> = [
170
+ ['timeout', 'report'],
171
+ ['restart', 'resume'],
172
+ ['sigterm', 'resume'],
173
+ ['unknown', 'resume'],
174
+ [null, 'resume'],
175
+ ['stop', null],
176
+ ]
177
+ for (const [endedVia, expected] of cases) {
178
+ it(`maps ended_via=${String(endedVia)} → ${String(expected)}`, () => {
179
+ expect(selectResumeBuilder(endedVia)).toBe(expected)
180
+ })
181
+ }
182
+ })
@@ -54,7 +54,6 @@ function makeDeps(
54
54
  markdownToHtml: (t) => `<b>${t}</b>`,
55
55
  escapeMarkdownV2: (t) => `\\${t}\\`,
56
56
  repairEscapedWhitespace: (t) => t,
57
- takeHandoffPrefix: () => '',
58
57
  assertAllowedChat: () => {},
59
58
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
60
59
  disableLinkPreview: true,
@@ -40,7 +40,6 @@ function makeDeps(
40
40
  markdownToHtml: (t) => realMarkdownToHtml(t),
41
41
  escapeMarkdownV2: (t) => t,
42
42
  repairEscapedWhitespace: (t) => t,
43
- takeHandoffPrefix: () => '',
44
43
  assertAllowedChat: () => {},
45
44
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
46
45
  disableLinkPreview: true,
@@ -35,7 +35,6 @@ function makeDeps(
35
35
  markdownToHtml: (t) => `<b>${t}</b>`,
36
36
  escapeMarkdownV2: (t) => `\\${t}\\`,
37
37
  repairEscapedWhitespace: (t) => t,
38
- takeHandoffPrefix: () => '',
39
38
  assertAllowedChat: () => {},
40
39
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
41
40
  disableLinkPreview: true,
@@ -104,29 +103,6 @@ describe('handleStreamReply', () => {
104
103
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
105
104
  })
106
105
 
107
- it('prepends handoff prefix on first chunk only', async () => {
108
- const state = makeState()
109
- const deps = makeDeps(bot, {
110
- takeHandoffPrefix: vi.fn<(fmt: string) => string>(() => '↩️ '),
111
- })
112
-
113
- // First call: prefix applied
114
- const p1 = handleStreamReply({ chat_id: '1', text: 'first' }, state, deps)
115
- await microtaskFlush()
116
- await p1
117
- // Prefix is prepended AFTER format rendering (it's already format-safe
118
- // because takeHandoffPrefix takes the format tag).
119
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('↩️ <b>first</b>')
120
-
121
- // Second call: handoff not consumed again
122
- vi.advanceTimersByTime(1000)
123
- const p2 = handleStreamReply({ chat_id: '1', text: 'second' }, state, deps)
124
- await microtaskFlush()
125
- await p2
126
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('<b>second</b>')
127
- expect(deps.takeHandoffPrefix).toHaveBeenCalledTimes(1)
128
- })
129
-
130
106
  it('throws when text exceeds 4096 (no silent id:pending)', async () => {
131
107
  // Pins the bug found in prod: a >4096-char text would hit draft-
132
108
  // stream's length guard, silently stop, and the handler would return
@@ -88,7 +88,6 @@ function setup(opts: { progressCardActive?: boolean } = {}): Fixture {
88
88
  markdownToHtml: (t) => `<b>${t}</b>`, // stream_reply: bold
89
89
  escapeMarkdownV2: (t) => t,
90
90
  repairEscapedWhitespace: (t) => t,
91
- takeHandoffPrefix: () => '',
92
91
  assertAllowedChat: () => {},
93
92
  resolveThreadId: () => undefined,
94
93
  disableLinkPreview: true,
@@ -407,7 +407,6 @@ function makeActivityDeps(
407
407
  markdownToHtml: (t) => t,
408
408
  escapeMarkdownV2: (t) => t,
409
409
  repairEscapedWhitespace: (t) => t,
410
- takeHandoffPrefix: () => '',
411
410
  assertAllowedChat: () => {},
412
411
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
413
412
  disableLinkPreview: true,
@@ -624,13 +624,17 @@ describe('Bug 3 — stalled-row sweeper: watcher must call recordSubagentStall i
624
624
  h.watcher.stop()
625
625
  })
626
626
 
627
- it('does not call stall for historical entries (pre-existing at boot)', () => {
627
+ it('does not call stall for historical (done-at-boot) entries', () => {
628
+ // A worker that already FINISHED before boot (turn_end present) stays
629
+ // historical and must not write stall rows. A still-RUNNING file at
630
+ // boot is a different case — Gap 1 promotes it to live so it DOES get
631
+ // the stall safety net (covered in subagent-watcher-handback-gaps).
628
632
  const agentDir = '/home/user/.switchroom/agents/myagent'
629
633
  const subagentsDir = `${agentDir}/.claude/projects/p1/session-abc/subagents`
630
634
  const jsonlStem = 'hist-agent'
631
635
  const toolUseId = 'toolu_hist001'
632
636
  const jsonlPath = `${subagentsDir}/agent-${jsonlStem}.jsonl`
633
- const content = buildJSONL(subAgentUserMsg('Old task'))
637
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
634
638
 
635
639
  const db = makeInMemoryDb({
636
640
  [toolUseId]: { id: toolUseId, jsonl_agent_id: jsonlStem, status: 'running' },
@@ -648,7 +652,7 @@ describe('Bug 3 — stalled-row sweeper: watcher must call recordSubagentStall i
648
652
  db,
649
653
  })
650
654
 
651
- // Do NOT flip historical entry is historical by default (file at boot)
655
+ // Done-at-boot stays historical (not promoted); no stall write fires.
652
656
  h.advance(65_000)
653
657
 
654
658
  const stallDbCalls = db._calls.filter(