switchroom 0.14.21 → 0.14.22

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 (39) 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 +640 -509
  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/tests/agent-dir.test.ts +25 -0
  21. package/telegram-plugin/tests/e2e.test.ts +2 -77
  22. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  23. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  24. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  25. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  26. package/telegram-plugin/tests/races.test.ts +0 -26
  27. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  28. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  29. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  30. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  31. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  32. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  33. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  34. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  35. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  36. package/telegram-plugin/tool-activity-summary.ts +55 -0
  37. package/telegram-plugin/uat/driver.ts +3 -1
  38. package/telegram-plugin/handoff-continuity.ts +0 -206
  39. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -28,11 +28,12 @@
28
28
  * updated_at INTEGER NOT NULL
29
29
  *
30
30
  * Boot-time usage:
31
- * On every gateway boot, call `markOrphanedAsRestarted(db)` immediately
32
- * after opening the DB. Any turn with `ended_at IS NULL` was killed
33
- * mid-flight (SIGKILL, OOM, power loss) — it never got a chance to write
34
- * a clean-shutdown marker. Stage 3 of simplify-restart will wire this up
35
- * from the gateway entry point.
31
+ * On every gateway boot, call `markOrphanedWithTimeoutClassification(db, …)`
32
+ * immediately after opening the DB. Any turn with `ended_at IS NULL` was
33
+ * killed mid-flight (SIGKILL, OOM, power loss, operator restart) — it never
34
+ * got a chance to write a clean-shutdown marker. The classifier stamps the
35
+ * in-flight turn `'timeout'` when its hang-marker is stale and `'restart'`
36
+ * otherwise; the gateway then resumes or reports accordingly.
36
37
  */
37
38
 
38
39
  import { chmodSync, mkdirSync } from 'fs'
@@ -98,6 +99,15 @@ export interface Turn {
98
99
  user_prompt_preview: string | null
99
100
  assistant_reply_preview: string | null
100
101
  tool_call_count: number | null
102
+ /**
103
+ * Forensic snapshot persisted by the boot-time classifier when a turn is
104
+ * stamped `ended_via='timeout'` (the hang-watchdog window elapsed with no
105
+ * tool progress). Carries the idle duration so a *later* boot can rebuild
106
+ * the watchdog-report inbound after the on-disk turn-active marker — the
107
+ * only live source of the idle age — has already been swept. Null for
108
+ * cleanly-restarted (`'restart'`) orphans.
109
+ */
110
+ interrupt_reason: string | null
101
111
  created_at: number
102
112
  updated_at: number
103
113
  }
@@ -137,6 +147,7 @@ const SCHEMA_SQL = `
137
147
  user_prompt_preview TEXT,
138
148
  assistant_reply_preview TEXT,
139
149
  tool_call_count INTEGER,
150
+ interrupt_reason TEXT,
140
151
  created_at INTEGER NOT NULL,
141
152
  updated_at INTEGER NOT NULL
142
153
  );
@@ -151,13 +162,21 @@ const PHASE1_MIGRATIONS = [
151
162
  `ALTER TABLE turns ADD COLUMN tool_call_count INTEGER`,
152
163
  ]
153
164
 
165
+ // Column added for honest-restart-resume. Persists the idle snapshot the
166
+ // boot classifier captures when stamping a turn 'timeout' (see
167
+ // `markOrphanedWithTimeoutClassification`).
168
+ const PHASE2_MIGRATIONS = [
169
+ `ALTER TABLE turns ADD COLUMN interrupt_reason TEXT`,
170
+ ]
171
+
154
172
  function applySchema(db: SqliteDatabase): void {
155
173
  db.exec('PRAGMA journal_mode = WAL')
156
174
  db.exec('PRAGMA synchronous = NORMAL')
157
175
  db.exec(SCHEMA_SQL)
158
- // Run migrations for Phase 1 columns. SQLite doesn't support
159
- // "ADD COLUMN IF NOT EXISTS", so we swallow the "duplicate column" error.
160
- for (const sql of PHASE1_MIGRATIONS) {
176
+ // Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
177
+ // we swallow the "duplicate column" error to stay idempotent on
178
+ // pre-existing registry.db files.
179
+ for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS]) {
161
180
  try {
162
181
  db.exec(sql)
163
182
  } catch (err) {
@@ -225,6 +244,7 @@ interface RawTurnRow {
225
244
  user_prompt_preview: string | null
226
245
  assistant_reply_preview: string | null
227
246
  tool_call_count: number | null
247
+ interrupt_reason: string | null
228
248
  created_at: number
229
249
  updated_at: number
230
250
  }
@@ -244,6 +264,7 @@ function mapRow(row: RawTurnRow): Turn {
244
264
  user_prompt_preview: row.user_prompt_preview,
245
265
  assistant_reply_preview: row.assistant_reply_preview,
246
266
  tool_call_count: row.tool_call_count,
267
+ interrupt_reason: row.interrupt_reason,
247
268
  created_at: row.created_at,
248
269
  updated_at: row.updated_at,
249
270
  }
@@ -283,7 +304,7 @@ export function recordTurnStart(db: SqliteDatabase, args: RecordTurnStartArgs):
283
304
  * tool-call count.
284
305
  *
285
306
  * No-ops gracefully if `turnKey` is not found (turn may have already been
286
- * swept by `markOrphanedAsRestarted` on a prior boot).
307
+ * swept by `markOrphanedWithTimeoutClassification` on a prior boot).
287
308
  */
288
309
  export function recordTurnEnd(db: SqliteDatabase, args: RecordTurnEndArgs): void {
289
310
  const now = Date.now()
@@ -327,27 +348,96 @@ export function findOrphanedTurns(db: SqliteDatabase, chatId: string): Turn[] {
327
348
  return rows.map(mapRow)
328
349
  }
329
350
 
351
+ export interface OrphanClassifyOpts {
352
+ /**
353
+ * `turnKey` from the on-disk `turn-active.json` marker — the single
354
+ * in-flight turn the hang-watchdog tracks. Null when no marker is
355
+ * present at boot (the previous process exited cleanly between turns).
356
+ */
357
+ markerTurnKey?: string | null
358
+ /**
359
+ * Age in ms of the `turn-active.json` marker's mtime at boot, or null
360
+ * when no marker is present. The marker's mtime is bumped on every
361
+ * tool_use, so this is "ms since the last observable progress" of the
362
+ * in-flight turn.
363
+ */
364
+ markerAgeMs?: number | null
365
+ /**
366
+ * Hang-watchdog threshold in ms (`TURN_HANG_SECS * 1000`, default
367
+ * 300_000). A marker older than this means the in-flight turn made no
368
+ * tool progress for at least the watchdog window — i.e. it was (or,
369
+ * under Docker where the watchdog is disabled, *would have been*)
370
+ * killed as a hang rather than cleanly restarted. That distinction is
371
+ * the whole point: a hung turn is reported, a live one is resumed.
372
+ */
373
+ hangThresholdMs: number
374
+ /**
375
+ * Opaque snapshot persisted to `interrupt_reason` for the
376
+ * timeout-classified turn so a later boot can rebuild the watchdog
377
+ * report after the marker has been swept.
378
+ */
379
+ reasonSnapshot?: string | null
380
+ /** Injectable clock for tests. */
381
+ now?: number
382
+ }
383
+
384
+ export interface OrphanClassifyResult {
385
+ /** Total rows stamped (timeout + restart). */
386
+ reaped: number
387
+ /** turn_key stamped 'timeout', or null if none qualified as a hang. */
388
+ timeoutTurnKey: string | null
389
+ }
390
+
330
391
  /**
331
- * Boot-time reaper. Sweeps ALL turns (across all chats) that have
332
- * `ended_at IS NULL` and stamps them with `ended_via = 'restart'` and
333
- * `ended_at = now()`.
392
+ * Boot-time reaper + classifier. Sweeps ALL turns with `ended_at IS NULL`
393
+ * (killed mid-flight: SIGKILL / OOM / hard reboot / operator restart) and
394
+ * stamps an `ended_via`:
334
395
  *
335
- * Call this once, immediately after `openTurnsDb`, before any new turns
336
- * are recorded for the current boot. That way the current boot's turns
337
- * are cleanly separable from orphans inherited from the prior process.
396
+ * - the in-flight turn (matched by `markerTurnKey`) is stamped
397
+ * `'timeout'` IFF its marker is older than `hangThresholdMs` it
398
+ * stalled with no tool progress for the full watchdog window, so it's
399
+ * reported-not-resumed; its `interrupt_reason` carries `reasonSnapshot`.
400
+ * - every other open turn (and the in-flight one when it was making
401
+ * progress) is stamped `'restart'` — a clean interrupt, eligible for
402
+ * blanket resume.
338
403
  *
339
- * Returns the number of rows updated.
404
+ * Call this once immediately after `openTurnsDb`, BEFORE any new turns are
405
+ * recorded for the current boot, and BEFORE the turn-active marker is
406
+ * swept (the classifier needs the marker's mtime).
340
407
  */
341
- export function markOrphanedAsRestarted(db: SqliteDatabase): number {
342
- const now = Date.now()
343
- const result = db.prepare(`
408
+ export function markOrphanedWithTimeoutClassification(
409
+ db: SqliteDatabase,
410
+ opts: OrphanClassifyOpts,
411
+ ): OrphanClassifyResult {
412
+ const now = opts.now ?? Date.now()
413
+ const isHang =
414
+ opts.markerAgeMs != null &&
415
+ opts.markerAgeMs >= opts.hangThresholdMs &&
416
+ opts.markerTurnKey != null &&
417
+ opts.markerTurnKey.length > 0
418
+
419
+ let timeoutTurnKey: string | null = null
420
+ if (isHang) {
421
+ const r = db.prepare(`
422
+ UPDATE turns
423
+ SET ended_at = ?,
424
+ ended_via = 'timeout',
425
+ interrupt_reason = ?,
426
+ updated_at = ?
427
+ WHERE turn_key = ? AND ended_at IS NULL
428
+ `).run(now, opts.reasonSnapshot ?? null, now, opts.markerTurnKey) as { changes: number }
429
+ if (r.changes > 0) timeoutTurnKey = opts.markerTurnKey ?? null
430
+ }
431
+
432
+ const rest = db.prepare(`
344
433
  UPDATE turns
345
434
  SET ended_at = ?,
346
435
  ended_via = 'restart',
347
436
  updated_at = ?
348
437
  WHERE ended_at IS NULL
349
438
  `).run(now, now) as { changes: number }
350
- return result.changes
439
+
440
+ return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
351
441
  }
352
442
 
353
443
  /**
@@ -392,26 +482,41 @@ export function listTurnsForAgent(
392
482
  return rows.map(mapRow)
393
483
  }
394
484
 
485
+ /** ended_via values that mean "this turn did not finish on its own". */
486
+ const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
487
+ 'restart',
488
+ 'sigterm',
489
+ 'timeout',
490
+ 'unknown',
491
+ ])
492
+
395
493
  /**
396
- * Find the single most-recently-started turn that ended via an interrupt
397
- * (`'restart'` | `'sigterm'` | `'timeout'`) OR is still open
398
- * (`ended_at IS NULL`). Used by Stage 4 to surface "you had pending work"
399
- * to the agent on cold start.
494
+ * Return the single most-recently-started turn IFF it was interrupted
495
+ * (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
496
+ * unknown}). Returns null when the latest turn ended cleanly (`'stop'`)
497
+ * or there are no turns at all.
400
498
  *
401
- * Returns null if no such turn exists (clean boot — last turn ended 'stop').
499
+ * This is the resume gate. Keying on the *latest* turn (not "latest
500
+ * interrupted turn anywhere in history") is deliberate: once the agent
501
+ * resumes and that follow-up turn ends `'stop'`, the latest turn is clean
502
+ * and this returns null — so a completed resume is never re-fired on the
503
+ * next restart. The older `findMostRecentInterruptedTurn` had the inverse
504
+ * bug: a clean latest turn didn't shadow a stale interrupted one, so it
505
+ * would resurface already-handled work indefinitely.
402
506
  *
403
- * Note on ordering: we use `started_at DESC` (not `updated_at`) so the
404
- * boot-time reaper (which mass-stamps orphans with the SAME `ended_at` /
405
- * `updated_at`) doesn't reorder them; the temporal "last turn" is what
406
- * the user remembers, and that's `started_at`.
507
+ * Ordering uses `started_at DESC` (not `updated_at`) so the boot reaper,
508
+ * which mass-stamps orphans with identical timestamps, can't reorder the
509
+ * temporal "last turn" the user actually remembers.
407
510
  */
408
- export function findMostRecentInterruptedTurn(db: SqliteDatabase): Turn | null {
511
+ export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
409
512
  const row = db.prepare(`
410
513
  SELECT * FROM turns
411
- WHERE ended_at IS NULL
412
- OR ended_via IN ('restart', 'sigterm', 'timeout')
413
514
  ORDER BY started_at DESC
414
515
  LIMIT 1
415
516
  `).get() as RawTurnRow | undefined
416
- return row ? mapRow(row) : null
517
+ if (!row) return null
518
+ const turn = mapRow(row)
519
+ if (turn.ended_at == null) return turn
520
+ if (turn.ended_via != null && INTERRUPTED_VIA.has(turn.ended_via)) return turn
521
+ return null
417
522
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Contract:
10
10
  * - First call for a chat+thread: creates a stream via
11
- * createStreamController, optionally prepending a handoff prefix.
11
+ * createStreamController.
12
12
  * - Subsequent calls: reuse the existing stream, push the new text.
13
13
  * - `done=true`: finalize, delete the map entry, fire status-reaction
14
14
  * completion, and (if history enabled) record the final message.
@@ -171,8 +171,6 @@ export interface StreamReplyDeps {
171
171
  escapeMarkdownV2: (text: string) => string
172
172
  /** Whitespace repair applied to the raw caller text. */
173
173
  repairEscapedWhitespace: (text: string) => string
174
- /** Resolves the handoff prefix for a first-chunk stream. Empty string if none. */
175
- takeHandoffPrefix: (format: 'html' | 'markdownv2' | 'text') => string
176
174
  /** Validates the chat id against the access list. Throws on deny. */
177
175
  assertAllowedChat: (chatId: string) => void
178
176
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -445,14 +443,6 @@ export async function handleStreamReply(
445
443
  streamExisted,
446
444
  })
447
445
 
448
- // First chunk of a session: consume any pending handoff prefix.
449
- if (!stream) {
450
- const prefix = deps.takeHandoffPrefix(
451
- format === 'html' ? 'html' : format === 'markdownv2' ? 'markdownv2' : 'text',
452
- )
453
- if (prefix.length > 0) effectiveText = prefix + effectiveText
454
- }
455
-
456
446
  if (!stream) {
457
447
  // Resolve the effective quote-reply target. Explicit `reply_to` wins;
458
448
  // otherwise (unless the caller opted out with `quote:false`) fall back
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { resolveAgentDirFromEnv } from "../agent-dir.js";
3
+
4
+ describe("resolveAgentDirFromEnv", () => {
5
+ const prior = process.env.TELEGRAM_STATE_DIR;
6
+ afterEach(() => {
7
+ if (prior === undefined) delete process.env.TELEGRAM_STATE_DIR;
8
+ else process.env.TELEGRAM_STATE_DIR = prior;
9
+ });
10
+
11
+ it("returns dirname of TELEGRAM_STATE_DIR", () => {
12
+ process.env.TELEGRAM_STATE_DIR = "/foo/bar/agent/telegram";
13
+ expect(resolveAgentDirFromEnv()).toBe("/foo/bar/agent");
14
+ });
15
+
16
+ it("returns null when env unset", () => {
17
+ delete process.env.TELEGRAM_STATE_DIR;
18
+ expect(resolveAgentDirFromEnv()).toBeNull();
19
+ });
20
+
21
+ it("returns null when env is empty string", () => {
22
+ process.env.TELEGRAM_STATE_DIR = " ";
23
+ expect(resolveAgentDirFromEnv()).toBeNull();
24
+ });
25
+ });
@@ -10,7 +10,7 @@
10
10
  * of this test file and brittle w.r.t. upstream churn.
11
11
  *
12
12
  * Instead, following the existing project convention
13
- * (see steering.test.ts, handoff-continuity.test.ts), we exercise each
13
+ * (see steering.test.ts), we exercise each
14
14
  * specified scenario through the same pure helper modules that server.ts
15
15
  * calls. Where a scenario lives inside server.ts's in-memory state
16
16
  * (activeTurnStartedAt, activeStatusReactions, suppressPtyPreview), we
@@ -18,23 +18,13 @@
18
18
  * server.ts uses. The helpers and the state shape are the contract —
19
19
  * if they don't regress, the integrated behaviour doesn't regress.
20
20
  */
21
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
22
- import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
23
- import { tmpdir } from 'node:os'
24
- import { join } from 'node:path'
21
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
25
22
 
26
23
  import {
27
24
  parseQueuePrefix,
28
25
  formatPriorAssistantPreview,
29
26
  buildChannelMetaAttributes,
30
27
  } from '../steering.js'
31
- import {
32
- consumeHandoffTopic,
33
- readHandoffTopic,
34
- formatHandoffLine,
35
- shouldShowHandoffLine,
36
- HANDOFF_TOPIC_FILENAME,
37
- } from '../handoff-continuity.js'
38
28
  import {
39
29
  isContextExhaustionText,
40
30
  shouldArmOrphanedReplyTimeout,
@@ -64,7 +54,6 @@ interface PluginState {
64
54
  currentSessionChatId: string | null
65
55
  currentSessionThreadId: number | undefined
66
56
  currentTurnStartedAt: number
67
- handoffTopicUsed: boolean
68
57
  }
69
58
 
70
59
  function freshState(): PluginState {
@@ -75,7 +64,6 @@ function freshState(): PluginState {
75
64
  currentSessionChatId: null,
76
65
  currentSessionThreadId: undefined,
77
66
  currentTurnStartedAt: 0,
78
- handoffTopicUsed: false,
79
67
  }
80
68
  }
81
69
 
@@ -271,69 +259,6 @@ describe('E2E: turn lifecycle cleanup', () => {
271
259
  })
272
260
  })
273
261
 
274
- // ---------------------------------------------------------------------------
275
- // Handoff continuity
276
- // ---------------------------------------------------------------------------
277
-
278
- describe('E2E: handoff continuity', () => {
279
- let tmp: string
280
- const priorEnv = { ...process.env }
281
-
282
- beforeEach(() => {
283
- tmp = mkdtempSync(join(tmpdir(), 'handoff-e2e-'))
284
- })
285
-
286
- afterEach(() => {
287
- rmSync(tmp, { recursive: true, force: true })
288
- process.env = { ...priorEnv }
289
- })
290
-
291
- it('bootstrap with sidecar + show-line=true → first reply prepends the line', () => {
292
- writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'shipped the feature\n', 'utf8')
293
- process.env.SWITCHROOM_HANDOFF_SHOW_LINE = 'true'
294
- expect(shouldShowHandoffLine()).toBe(true)
295
- const topic = consumeHandoffTopic(tmp)
296
- expect(topic).toBe('shipped the feature')
297
- const line = formatHandoffLine(topic!, 'html')
298
- expect(line).toContain('shipped the feature')
299
- expect(line).toMatch(/^<i>/)
300
- })
301
-
302
- it('bootstrap with sidecar + show-line=false → no prefix', () => {
303
- writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'x\n', 'utf8')
304
- process.env.SWITCHROOM_HANDOFF_SHOW_LINE = 'false'
305
- expect(shouldShowHandoffLine()).toBe(false)
306
- })
307
-
308
- it('bootstrap with no sidecar → no prefix', () => {
309
- expect(readHandoffTopic(tmp)).toBeNull()
310
- expect(consumeHandoffTopic(tmp)).toBeNull()
311
- })
312
-
313
- it('consuming topic is one-shot — second call returns null + sidecar deleted', () => {
314
- writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'topic\n', 'utf8')
315
- expect(consumeHandoffTopic(tmp)).toBe('topic')
316
- expect(existsSync(join(tmp, HANDOFF_TOPIC_FILENAME))).toBe(false)
317
- expect(consumeHandoffTopic(tmp)).toBeNull()
318
- })
319
-
320
- it('stream_reply: once topic consumed, subsequent stream chunks do not re-prefix', () => {
321
- // Model: the plugin tracks handoffTopicUsed after first reply/stream_reply
322
- // use. The second and later stream edits on the same stream read the flag
323
- // and skip prepending.
324
- writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 't\n', 'utf8')
325
- const s = freshState()
326
- expect(s.handoffTopicUsed).toBe(false)
327
- // first chunk
328
- const topic = consumeHandoffTopic(tmp)
329
- expect(topic).toBe('t')
330
- s.handoffTopicUsed = true
331
- // simulate next chunk arriving — should not consume
332
- expect(consumeHandoffTopic(tmp)).toBeNull()
333
- expect(s.handoffTopicUsed).toBe(true)
334
- })
335
- })
336
-
337
262
  // ---------------------------------------------------------------------------
338
263
  // Context exhaustion
339
264
  // ---------------------------------------------------------------------------
@@ -106,6 +106,51 @@ describe('spoolId — stable dedup key', () => {
106
106
  // messageId > 0 → legacy m:<chat>:<msgId> still wins.
107
107
  expect(a).toBe('m:c1:555')
108
108
  })
109
+ // honest-restart-resume: a boot-resume inbound is minted with a fresh
110
+ // ts/messageId every boot, so without a turn-keyed id an operator who
111
+ // restarts twice before the agent drains the first resume would stack
112
+ // N resumes of the same turn. Keying on resume_turn_key collapses them.
113
+ it('resume_interrupted → s:resume:<turn_key>, stable across boots (fresh ts/messageId)', () => {
114
+ const a = spoolId(
115
+ msg({
116
+ messageId: 1700_000_000_000,
117
+ ts: 1700_000_000_000,
118
+ meta: { source: 'resume_interrupted', resume_turn_key: '12345:11' },
119
+ }),
120
+ )
121
+ const b = spoolId(
122
+ msg({
123
+ messageId: 1700_000_999_999,
124
+ ts: 1700_000_999_999,
125
+ meta: { source: 'resume_interrupted', resume_turn_key: '12345:11' },
126
+ }),
127
+ )
128
+ expect(a).toBe('s:resume:12345:11')
129
+ expect(b).toBe(a)
130
+ })
131
+ it('resume_watchdog_timeout shares the s:resume namespace (one turn is one or the other)', () => {
132
+ const interrupted = spoolId(
133
+ msg({ messageId: 0, meta: { source: 'resume_interrupted', resume_turn_key: 'k:1' } }),
134
+ )
135
+ const timeout = spoolId(
136
+ msg({ messageId: 0, meta: { source: 'resume_watchdog_timeout', resume_turn_key: 'k:1' } }),
137
+ )
138
+ expect(timeout).toBe('s:resume:k:1')
139
+ expect(timeout).toBe(interrupted)
140
+ })
141
+ it('resume inbounds for distinct turns stay distinct', () => {
142
+ const a = spoolId(
143
+ msg({ messageId: 0, meta: { source: 'resume_interrupted', resume_turn_key: 'k:1' } }),
144
+ )
145
+ const b = spoolId(
146
+ msg({ messageId: 0, meta: { source: 'resume_interrupted', resume_turn_key: 'k:2' } }),
147
+ )
148
+ expect(a).not.toBe(b)
149
+ })
150
+ it('resume source without a turn_key falls back to legacy id (no crash)', () => {
151
+ const a = spoolId(msg({ messageId: 777, meta: { source: 'resume_interrupted' }, ts: 100 }))
152
+ expect(a).toBe('m:c1:777')
153
+ })
109
154
  })
110
155
 
111
156
  describe('inbound-spool — subagent_handback dedup across restart re-build (#1719)', () => {
@@ -31,7 +31,6 @@ function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamRep
31
31
  markdownToHtml: (t) => realMarkdownToHtml(t),
32
32
  escapeMarkdownV2: (t) => t,
33
33
  repairEscapedWhitespace: (t) => t,
34
- takeHandoffPrefix: () => '',
35
34
  assertAllowedChat: () => {},
36
35
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
37
36
  disableLinkPreview: true,
@@ -437,7 +437,6 @@ describe('wrapBot + handleStreamReply + reply ordering', () => {
437
437
  markdownToHtml: (t) => t,
438
438
  escapeMarkdownV2: (t) => t,
439
439
  repairEscapedWhitespace: (t) => t,
440
- takeHandoffPrefix: () => '',
441
440
  assertAllowedChat: () => {},
442
441
  resolveThreadId: () => undefined,
443
442
  disableLinkPreview: true,
@@ -31,7 +31,6 @@ function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamRep
31
31
  markdownToHtml: (t) => realMarkdownToHtml(t),
32
32
  escapeMarkdownV2: (t) => `ESC(${t})`,
33
33
  repairEscapedWhitespace: (t) => t,
34
- takeHandoffPrefix: () => '',
35
34
  assertAllowedChat: () => {},
36
35
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
37
36
  disableLinkPreview: true,
@@ -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.