switchroom 0.14.20 → 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 (53) hide show
  1. package/dist/agent-scheduler/index.js +2 -3
  2. package/dist/auth-broker/index.js +2 -3
  3. package/dist/cli/notion-write-pretool.mjs +2 -3
  4. package/dist/cli/switchroom.js +16 -8
  5. package/dist/host-control/main.js +2 -3
  6. package/dist/vault/approvals/kernel-server.js +2 -3
  7. package/dist/vault/broker/server.js +2 -3
  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 +655 -514
  15. package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
  16. package/telegram-plugin/gateway/gateway.ts +246 -83
  17. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  18. package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
  19. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  20. package/telegram-plugin/registry/turns-schema.ts +138 -33
  21. package/telegram-plugin/stream-reply-handler.ts +1 -11
  22. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  23. package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
  24. package/telegram-plugin/tests/e2e.test.ts +2 -77
  25. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  26. package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
  27. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  28. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  29. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  30. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
  31. package/telegram-plugin/tests/races.test.ts +0 -26
  32. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  33. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  34. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  35. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  36. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  37. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  38. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  39. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  40. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  41. package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
  42. package/telegram-plugin/tool-activity-summary.ts +55 -0
  43. package/telegram-plugin/uat/assertions.ts +53 -0
  44. package/telegram-plugin/uat/driver.ts +30 -0
  45. package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
  46. package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
  47. package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
  48. package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
  50. package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
  51. package/telegram-plugin/worker-activity-feed.ts +11 -5
  52. package/telegram-plugin/handoff-continuity.ts +0 -206
  53. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Pure builders for the synthetic inbounds the gateway injects at boot
3
+ * when it inherits an interrupted turn from the previous process.
4
+ *
5
+ * Two shapes, selected by how the prior turn ended (see
6
+ * `selectResumeBuilder`):
7
+ *
8
+ * - `resume_interrupted` — the turn was cut off mid-flight by an
9
+ * operator restart / SIGTERM / crash while it was still making
10
+ * progress. The agent should pick the work back up and tell the user
11
+ * it's resuming. Blanket resume regardless of how long ago — the
12
+ * elapsed time rides along so the model can frame it ("picking up the
13
+ * X you asked ~3h ago").
14
+ *
15
+ * - `resume_watchdog_timeout` — the turn stalled with no tool progress
16
+ * for the full hang-watchdog window and was (or would have been)
17
+ * killed as a hang. The agent must NOT silently resume; it reports
18
+ * what happened honestly and asks whether to retry or take a
19
+ * different angle. The honest cause is "no observable progress for N
20
+ * minutes" — the framework deliberately does not invent a deeper root
21
+ * cause, and neither should the model.
22
+ *
23
+ * Why a separate module (mirrors `vault-grant-inbound-builders.ts`): the
24
+ * InboundMessage shape is load-bearing. `meta.source` is what the bridge
25
+ * forwards verbatim and Claude Code renders as `<channel source="…">`, so
26
+ * the model keys on it to know this is a boot-resume turn rather than a
27
+ * human message. `meta.resume_turn_key` is the dedup anchor the spool
28
+ * uses (see `spoolId`) so a multi-restart sequence resumes a given turn
29
+ * exactly once. Pinning the builders against fixture tests keeps that
30
+ * contract honest without booting a real gateway.
31
+ */
32
+
33
+ import type { InboundMessage } from './ipc-protocol.js'
34
+ import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
35
+
36
+ /** Render an elapsed duration as a coarse, human-friendly approximation
37
+ * the model can drop straight into prose ("~3h ago"). Deliberately
38
+ * coarse — minute/hour/day buckets, never "2h 47m" precision the user
39
+ * doesn't care about on a resume. */
40
+ export function humanizeElapsed(ms: number): string {
41
+ if (!Number.isFinite(ms) || ms < 0) return 'an unknown amount of time'
42
+ const sec = Math.round(ms / 1000)
43
+ if (sec < 45) return 'moments'
44
+ const min = Math.round(sec / 60)
45
+ if (min < 60) return `~${min} min`
46
+ const hr = Math.round(min / 60)
47
+ if (hr < 24) return `~${hr}h`
48
+ const days = Math.round(hr / 24)
49
+ return `~${days} day${days === 1 ? '' : 's'}`
50
+ }
51
+
52
+ export interface ResumeInboundContext {
53
+ /** The interrupted turn, straight from the registry. */
54
+ turn: Turn
55
+ /** Wall-clock ms. Drives `ts`, `messageId`, and the elapsed framing.
56
+ * Defaults to Date.now(). */
57
+ nowMs?: number
58
+ }
59
+
60
+ function threadIdNum(turn: Turn): number | undefined {
61
+ if (turn.thread_id == null) return undefined
62
+ const n = Number(turn.thread_id)
63
+ return Number.isFinite(n) ? n : undefined
64
+ }
65
+
66
+ function promptClause(turn: Turn): string {
67
+ const p = turn.user_prompt_preview?.trim()
68
+ if (!p) return ''
69
+ // Quote-trim so a long preview doesn't bloat the channel body.
70
+ const snippet = p.length > 160 ? p.slice(0, 160) + '…' : p
71
+ return ` The request was: "${snippet}".`
72
+ }
73
+
74
+ /**
75
+ * Build the `resume_interrupted` inbound — a clean mid-flight interrupt
76
+ * the agent should pick back up.
77
+ */
78
+ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): InboundMessage {
79
+ const ts = ctx.nowMs ?? Date.now()
80
+ const elapsed = humanizeElapsed(ts - ctx.turn.started_at)
81
+ const meta: Record<string, string> = {
82
+ source: 'resume_interrupted',
83
+ resume_turn_key: ctx.turn.turn_key,
84
+ interrupted_via: ctx.turn.ended_via ?? 'restart',
85
+ started_at: String(ctx.turn.started_at),
86
+ }
87
+ if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
88
+ const threadId = threadIdNum(ctx.turn)
89
+ return {
90
+ type: 'inbound',
91
+ chatId: ctx.turn.chat_id,
92
+ ...(threadId != null ? { threadId } : {}),
93
+ messageId: ts,
94
+ user: 'switchroom',
95
+ userId: 0,
96
+ ts,
97
+ text:
98
+ `You just restarted. Your previous turn was interrupted ${elapsed} ago, ` +
99
+ `before it finished — it was cut off by a restart, not completed.` +
100
+ promptClause(ctx.turn) +
101
+ ` Pick that work back up now and continue it through to completion. ` +
102
+ `In your first message, briefly let the user know you're resuming what ` +
103
+ `was interrupted (mention roughly how long ago in plain language) so ` +
104
+ `they're not left wondering — then carry on with the actual task. Do ` +
105
+ `not ask whether to resume; just resume. If you genuinely can't tell ` +
106
+ `what the work was, say so and ask.`,
107
+ meta,
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build the `resume_watchdog_timeout` inbound — a stalled turn the agent
113
+ * must report (not silently resume).
114
+ *
115
+ * `idleMs` is the no-progress duration the boot classifier measured (the
116
+ * marker age). It is passed explicitly rather than read off the turn so
117
+ * the caller can recover it from the persisted `interrupt_reason` on a
118
+ * later boot when the live marker is gone.
119
+ */
120
+ export function buildResumeWatchdogReportInbound(
121
+ ctx: ResumeInboundContext & { idleMs: number },
122
+ ): InboundMessage {
123
+ const ts = ctx.nowMs ?? Date.now()
124
+ const idle = humanizeElapsed(ctx.idleMs)
125
+ const since = humanizeElapsed(ts - ctx.turn.started_at)
126
+ const toolClause =
127
+ ctx.turn.tool_call_count != null && ctx.turn.tool_call_count > 0
128
+ ? ` You'd run ${ctx.turn.tool_call_count} tool call${ctx.turn.tool_call_count === 1 ? '' : 's'} before it stalled.`
129
+ : ''
130
+ const meta: Record<string, string> = {
131
+ source: 'resume_watchdog_timeout',
132
+ resume_turn_key: ctx.turn.turn_key,
133
+ interrupted_via: 'timeout',
134
+ idle_ms: String(ctx.idleMs),
135
+ started_at: String(ctx.turn.started_at),
136
+ }
137
+ if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
138
+ if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
139
+ const threadId = threadIdNum(ctx.turn)
140
+ return {
141
+ type: 'inbound',
142
+ chatId: ctx.turn.chat_id,
143
+ ...(threadId != null ? { threadId } : {}),
144
+ messageId: ts,
145
+ user: 'switchroom',
146
+ userId: 0,
147
+ ts,
148
+ text:
149
+ `You just restarted. Your previous turn (started ${since} ago) was ` +
150
+ `killed by the hang-watchdog: it made no observable progress for ${idle} ` +
151
+ `and the watchdog restarts a turn that goes that long without activity.` +
152
+ toolClause +
153
+ promptClause(ctx.turn) +
154
+ ` Do NOT silently resume it — it may hang again the same way. Instead, ` +
155
+ `tell the user plainly what happened: that your last turn was killed ` +
156
+ `after ${idle} of no progress, and roughly what it was doing. Then ask ` +
157
+ `whether they want you to retry it or take a different angle. Report ` +
158
+ `only the honest cause — no observable progress for that long — don't ` +
159
+ `speculate about a deeper root cause you can't see.`,
160
+ meta,
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Decide which resume inbound (if any) a given interrupt warrants. Pure —
166
+ * the gateway calls this with the classified `ended_via` so the
167
+ * report-vs-resume policy lives in one testable place.
168
+ *
169
+ * - 'timeout' → 'report' (watchdog kill)
170
+ * - 'restart' | 'sigterm' | 'unknown' → 'resume' (clean interrupt)
171
+ * - 'stop' → null (finished; nothing to do)
172
+ */
173
+ export function selectResumeBuilder(
174
+ endedVia: TurnEndedVia | null,
175
+ ): 'resume' | 'report' | null {
176
+ if (endedVia === 'timeout') return 'report'
177
+ if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') return 'resume'
178
+ if (endedVia == null) return 'resume' // still-open at boot = killed mid-flight
179
+ return null
180
+ }
@@ -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
+ });
@@ -2,22 +2,40 @@
2
2
  * Unit tests for the A2 multi-attachment helpers
3
3
  * (telegram-plugin/gateway/coalesce-attachments.ts).
4
4
  *
5
- * These pin the two pure pieces of the multi-attachment fold-in that live
5
+ * These pin the pure pieces of the multi-attachment fold-in that live
6
6
  * outside gateway.ts so they can be exercised without loadAccess()/IPC:
7
- * 1. splitCoalescedAttachmentsprimary + capped extras, arrival order.
8
- * 2. buildExtraAttachmentMetanumbered meta fields starting at _2.
7
+ * 1. resolveCoalesceMaxAttachmentsthe runtime cap default (10).
8
+ * 2. splitCoalescedAttachmentsprimary + capped extras, arrival order.
9
+ * 3. buildExtraAttachmentMeta — numbered meta fields starting at _2.
9
10
  *
10
- * The default cap (1) MUST reproduce the historical single-attachment shape:
11
- * primary only, no extras, no numbered meta.
11
+ * A cap of 1 reproduces the historical single-attachment shape: primary
12
+ * only, no extras, no numbered meta.
12
13
  */
13
14
 
14
15
  import { describe, expect, it } from 'vitest'
15
16
  import {
16
17
  splitCoalescedAttachments,
17
18
  buildExtraAttachmentMeta,
19
+ resolveCoalesceMaxAttachments,
20
+ DEFAULT_MAX_ATTACHMENTS,
18
21
  type ResolvedExtraAttachment,
19
22
  } from '../gateway/coalesce-attachments.js'
20
23
 
24
+ describe('resolveCoalesceMaxAttachments (default 10 = full album)', () => {
25
+ it('defaults to 10 when unset', () => {
26
+ expect(resolveCoalesceMaxAttachments(undefined)).toBe(10)
27
+ expect(DEFAULT_MAX_ATTACHMENTS).toBe(10)
28
+ })
29
+ it('honours an explicit operator cap', () => {
30
+ expect(resolveCoalesceMaxAttachments(1)).toBe(1)
31
+ expect(resolveCoalesceMaxAttachments(25)).toBe(25)
32
+ })
33
+ it('floors a 0 / negative cap at 1 (never strips the only attachment)', () => {
34
+ expect(resolveCoalesceMaxAttachments(0)).toBe(1)
35
+ expect(resolveCoalesceMaxAttachments(-5)).toBe(1)
36
+ })
37
+ })
38
+
21
39
  interface Entry {
22
40
  text: string
23
41
  att?: string
@@ -26,7 +44,7 @@ interface Entry {
26
44
  const has = (e: Entry): boolean => e.att != null
27
45
 
28
46
  describe('splitCoalescedAttachments', () => {
29
- it('default cap 1: keeps only the first attachment as primary, no extras', () => {
47
+ it('cap 1: keeps only the first attachment as primary, no extras', () => {
30
48
  const entries: Entry[] = [
31
49
  { text: 'a', att: 'photo-1' },
32
50
  { text: 'b', att: 'photo-2' },
@@ -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
  // ---------------------------------------------------------------------------