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.
- package/dist/agent-scheduler/index.js +0 -1
- package/dist/auth-broker/index.js +0 -1
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +14 -6
- package/dist/host-control/main.js +0 -1
- package/dist/vault/approvals/kernel-server.js +0 -1
- package/dist/vault/broker/server.js +0 -1
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +11 -24
- package/profiles/_shared/telegram-style.md.hbs +2 -2
- package/profiles/default/CLAUDE.md.hbs +4 -1
- package/skills/switchroom-runtime/SKILL.md +6 -16
- package/telegram-plugin/agent-dir.ts +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +788 -513
- package/telegram-plugin/gateway/gateway.ts +216 -61
- package/telegram-plugin/gateway/inbound-spool.ts +15 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
- package/telegram-plugin/registry/turns-schema.ts +138 -33
- package/telegram-plugin/stream-reply-handler.ts +1 -11
- package/telegram-plugin/subagent-watcher.ts +79 -5
- package/telegram-plugin/tests/agent-dir.test.ts +25 -0
- package/telegram-plugin/tests/e2e.test.ts +2 -77
- package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/races.test.ts +0 -26
- package/telegram-plugin/tests/registry-turns.test.ts +106 -29
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tests/turns-writer.test.ts +16 -6
- package/telegram-plugin/tool-activity-summary.ts +55 -0
- package/telegram-plugin/uat/driver.ts +3 -1
- package/telegram-plugin/handoff-continuity.ts +0 -206
- 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
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
293
|
+
// markOrphanedWithTimeoutClassification — no-hang-marker (clean restart) path
|
|
284
294
|
// ---------------------------------------------------------------------------
|
|
285
295
|
|
|
286
|
-
describe('
|
|
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
|
|
293
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
342
|
+
reapAsRestart(db)
|
|
332
343
|
|
|
333
344
|
expect(findOrphanedTurns(db, '666')).toHaveLength(0)
|
|
334
345
|
db.close()
|
|
335
346
|
})
|
|
336
347
|
|
|
337
|
-
it('
|
|
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(
|
|
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 (
|
|
357
|
+
it('is safe to call on an empty DB (reaps 0, no error)', () => {
|
|
347
358
|
const db = openTurnsDbInMemory()
|
|
348
|
-
expect(
|
|
359
|
+
expect(reapAsRestart(db).reaped).toBe(0)
|
|
349
360
|
db.close()
|
|
350
361
|
})
|
|
351
362
|
})
|
|
352
363
|
|
|
353
364
|
// ---------------------------------------------------------------------------
|
|
354
|
-
//
|
|
365
|
+
// markOrphanedWithTimeoutClassification — hang-marker (timeout) path
|
|
355
366
|
// ---------------------------------------------------------------------------
|
|
356
367
|
|
|
357
|
-
describe('
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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('
|
|
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 =
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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(
|