switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import {
6
+ createHandbackOrphanRecovery,
7
+ formatOrphanEscalation,
8
+ } from '../gateway/handback-orphan-recovery.js'
9
+ import type { HandbackOrphanEscalation, PreTurnCardRecord } from '../gateway/handback-preturn-signal.js'
10
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
11
+
12
+ /**
13
+ * Unit test for the gateway-side EFFECTS of deterministic handback-orphan
14
+ * recovery, extracted out of gateway.ts (switchroom#2996 ratchet). The seam's
15
+ * DECISION logic is covered by `handback-preturn-signal.test.ts`; this file
16
+ * asserts the three effects behave as the seam's contract requires:
17
+ *
18
+ * - a genuine orphan's card is DELETED (never edited to a user-facing string)
19
+ * - a delete failure is swallowed, so a reap is never wedged by a Telegram error
20
+ * - the handback is re-injected through the pending-inbound buffer verbatim
21
+ * - retry exhaustion emits greppable telemetry, NOT a chat message
22
+ *
23
+ * Uses injected spies + an injected `writeLog`, so it runs identically under
24
+ * vitest and bun (no fake timers, no module mocks).
25
+ */
26
+
27
+ const record: PreTurnCardRecord = {
28
+ turnKey: 'preturn:chatA:_:1000',
29
+ chatId: 'chatA',
30
+ threadId: 7,
31
+ activityMessageId: 4242,
32
+ startedAt: 1000,
33
+ pinned: false,
34
+ }
35
+
36
+ const escalation: HandbackOrphanEscalation = {
37
+ statusKey: 'chatA:7',
38
+ chatId: 'chatA',
39
+ threadId: 7,
40
+ adoptTurnId: 'turn-abc',
41
+ reinjectCount: 2,
42
+ ageMs: 90_000,
43
+ }
44
+
45
+ function inbound(): InboundMessage {
46
+ return {
47
+ type: 'inbound',
48
+ chatId: 'chatA',
49
+ threadId: 7,
50
+ messageId: 900,
51
+ text: 'worker finished',
52
+ meta: { source: 'subagent_handback', handbackReinjectCount: '1' },
53
+ } as unknown as InboundMessage
54
+ }
55
+
56
+ describe('handback orphan recovery effects', () => {
57
+ it('deletes the frozen pre-turn card with its chat/message/thread identity', async () => {
58
+ const deleteMessage = vi.fn(async () => ({ ok: true }))
59
+ const r = createHandbackOrphanRecovery({
60
+ deleteMessage,
61
+ pushInbound: vi.fn(),
62
+ writeLog: vi.fn(),
63
+ })
64
+
65
+ await r.deleteCard(record)
66
+
67
+ expect(deleteMessage).toHaveBeenCalledTimes(1)
68
+ expect(deleteMessage).toHaveBeenCalledWith('chatA', 4242, 7)
69
+ })
70
+
71
+ it('swallows a delete failure so the reap is never wedged by a Telegram error', async () => {
72
+ const deleteMessage = vi.fn(async () => {
73
+ throw new Error('message to delete not found')
74
+ })
75
+ const r = createHandbackOrphanRecovery({
76
+ deleteMessage,
77
+ pushInbound: vi.fn(),
78
+ writeLog: vi.fn(),
79
+ })
80
+
81
+ await expect(r.deleteCard(record)).resolves.toBeUndefined()
82
+ })
83
+
84
+ it('re-injects the orphaned handback verbatim through the pending-inbound buffer', () => {
85
+ const pushInbound = vi.fn()
86
+ const r = createHandbackOrphanRecovery({
87
+ deleteMessage: vi.fn(async () => ({})),
88
+ pushInbound,
89
+ writeLog: vi.fn(),
90
+ })
91
+
92
+ const msg = inbound()
93
+ r.reinjectHandback(msg)
94
+
95
+ expect(pushInbound).toHaveBeenCalledTimes(1)
96
+ // Same object identity: the seam already stamped the retry counter on
97
+ // `meta`, and this effect must not rebuild or strip the inbound.
98
+ expect(pushInbound.mock.calls[0]?.[0]).toBe(msg)
99
+ })
100
+
101
+ it('escalates exhausted retries to telemetry with the fleet-health join keys, not to chat', () => {
102
+ const writeLog = vi.fn()
103
+ const pushInbound = vi.fn()
104
+ const deleteMessage = vi.fn(async () => ({}))
105
+ const r = createHandbackOrphanRecovery({ deleteMessage, pushInbound, writeLog })
106
+
107
+ r.escalateOrphan(escalation)
108
+
109
+ expect(writeLog).toHaveBeenCalledTimes(1)
110
+ const line = writeLog.mock.calls[0]?.[0] as string
111
+ expect(line).toContain('handback orphan escalation')
112
+ expect(line).toContain('key=chatA:7')
113
+ expect(line).toContain('turnId=turn-abc')
114
+ expect(line).toContain('reinjects=2')
115
+ expect(line).toContain('ageMs=90000')
116
+ expect(line.endsWith('\n')).toBe(true)
117
+ // Escalation is telemetry-only: it must not touch any chat transport.
118
+ expect(deleteMessage).not.toHaveBeenCalled()
119
+ expect(pushInbound).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('formatOrphanEscalation is a single line (one log record, not a multi-line blob)', () => {
123
+ const line = formatOrphanEscalation(escalation)
124
+ expect(line.trimEnd().split('\n')).toHaveLength(1)
125
+ })
126
+
127
+ it('the operator-facing "needs a nudge" string is gone from the recovery module', () => {
128
+ const src = readFileSync(new URL('../gateway/handback-orphan-recovery.ts', import.meta.url), 'utf8')
129
+ expect(src).not.toContain('it may need a nudge')
130
+ })
131
+ })
@@ -1,9 +1,13 @@
1
+ import { readFileSync } from 'node:fs'
2
+
1
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
4
 
3
5
  import {
4
6
  createHandbackPreturnSignal,
5
7
  isHandbackInbound,
8
+ HANDBACK_REINJECT_COUNT_META_KEY,
6
9
  PRETURN_TURNKEY_PREFIX,
10
+ type HandbackOrphanEscalation,
7
11
  type HandbackPreturnSignalDeps,
8
12
  type PreTurnCardRecord,
9
13
  } from '../gateway/handback-preturn-signal.js'
@@ -136,9 +140,13 @@ interface Harness {
136
140
  sched: ReturnType<typeof makeScheduler>
137
141
  sendChatAction: ReturnType<typeof vi.fn>
138
142
  openCard: ReturnType<typeof vi.fn>
139
- finalizeCard: ReturnType<typeof vi.fn>
143
+ deleteCard: ReturnType<typeof vi.fn>
144
+ reinjectHandback: ReturnType<typeof vi.fn>
145
+ escalateOrphan: ReturnType<typeof vi.fn>
140
146
  records: Map<string, PreTurnCardRecord>
141
- finalizedIds: number[]
147
+ deletedIds: number[]
148
+ reinjected: InboundMessage[]
149
+ escalations: HandbackOrphanEscalation[]
142
150
  }
143
151
 
144
152
  const harnesses: Harness[] = []
@@ -168,9 +176,19 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
168
176
  if (cur != null && cur.activityMessageId === activityMessageId) records.delete(turnKey)
169
177
  }
170
178
 
171
- const finalizedIds: number[] = []
172
- const finalizeCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
173
- finalizedIds.push(r.activityMessageId)
179
+ const deletedIds: number[] = []
180
+ const deleteCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
181
+ deletedIds.push(r.activityMessageId)
182
+ })
183
+
184
+ const reinjected: InboundMessage[] = []
185
+ const reinjectHandback = vi.fn<(m: InboundMessage) => void>((m) => {
186
+ reinjected.push(m)
187
+ })
188
+
189
+ const escalations: HandbackOrphanEscalation[] = []
190
+ const escalateOrphan = vi.fn<(e: HandbackOrphanEscalation) => void>((e) => {
191
+ escalations.push(e)
174
192
  })
175
193
 
176
194
  const signal = createHandbackPreturnSignal({
@@ -179,7 +197,9 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
179
197
  startTypingLoop: (c, t) => typing.start(c, t),
180
198
  stopTypingLoop: (c, t) => typing.stop(c, t),
181
199
  openCard,
182
- finalizeCard,
200
+ deleteCard,
201
+ reinjectHandback,
202
+ escalateOrphan,
183
203
  writeCardRecord,
184
204
  clearCardRecord,
185
205
  now: sched.now,
@@ -187,10 +207,28 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
187
207
  clearTimer: sched.clearTimer,
188
208
  debounceMs: 700,
189
209
  adoptTimeoutMs: 30_000,
210
+ // Default the delivered-window TTL to 0 so tests that exercise the reap
211
+ // MECHANICS (idle-orphan, #3544 typing, isClaudeBusy gate) see the reap fire
212
+ // at the adopt timeout exactly as before this fix. Tests that specifically
213
+ // assert the delivered-but-not-yet-streaming deferral opt into a real TTL.
214
+ deliveredTtlMs: 0,
190
215
  ...overrides,
191
216
  })
192
217
 
193
- const h: Harness = { signal, typing, sched, sendChatAction, openCard, finalizeCard, records, finalizedIds }
218
+ const h: Harness = {
219
+ signal,
220
+ typing,
221
+ sched,
222
+ sendChatAction,
223
+ openCard,
224
+ deleteCard,
225
+ reinjectHandback,
226
+ escalateOrphan,
227
+ records,
228
+ deletedIds,
229
+ reinjected,
230
+ escalations,
231
+ }
194
232
  harnesses.push(h)
195
233
  return h
196
234
  }
@@ -283,7 +321,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
283
321
  expect(h.signal.pendingCount()).toBe(0)
284
322
  })
285
323
 
286
- it('never-adopted handback self-reaps its frozen card past the TTL', async () => {
324
+ it('never-adopted handback self-reaps: DELETES the frozen card and re-injects (deterministic recovery, no nudge)', async () => {
287
325
  const h = makeHarness()
288
326
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatD', messageId: 77 }))
289
327
  await h.sched.advance(700)
@@ -291,10 +329,15 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
291
329
  expect(h.typing.activeCount()).toBe(1)
292
330
  expect(h.records.size).toBe(1)
293
331
 
294
- // No enqueue ever arrives. Past the adopt TTL the orphan self-reaps.
332
+ // No enqueue ever arrives. Past the adopt TTL the orphan self-reaps into
333
+ // deterministic recovery: the frozen card is DELETED (never rewritten to a
334
+ // user-facing "needs a nudge" string) and the handback is re-injected.
295
335
  await h.sched.advance(30_000)
296
336
 
297
- expect(h.finalizedIds).toEqual([resolvedId]) // finalized exactly THAT card
337
+ expect(h.deletedIds).toEqual([resolvedId]) // deleted exactly THAT card
338
+ expect(h.reinjected).toHaveLength(1) // re-injected for the machine to recover itself
339
+ expect(h.reinjected[0]!.meta[HANDBACK_REINJECT_COUNT_META_KEY]).toBe('1')
340
+ expect(h.escalations).toEqual([]) // first orphan → recover, do NOT escalate
298
341
  expect(h.typing.activeCount()).toBe(0) // typing stopped
299
342
  expect(h.records.size).toBe(0) // durable record cleared (at-most-once)
300
343
  expect(h.signal.pendingCount()).toBe(0) // map empty
@@ -326,18 +369,20 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
326
369
  // DEFER each time: no false orphan card, entry still live, typing still lit.
327
370
  await h.sched.advance(30_000)
328
371
  await h.sched.advance(30_000)
329
- expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
372
+ expect(h.deletedIds).toEqual([]) // NEVER reaped: card intact
373
+ expect(h.reinjected).toEqual([]) // NEVER re-injected while enqueued
330
374
  expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
331
375
  expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
332
376
 
333
377
  // The parent turn finally ends and the handback mints its own turn: it
334
- // adopts the SAME card (an edit, not a second send / not an orphan finalize).
378
+ // adopts the SAME card (an edit, not a second send / not an orphan reap).
335
379
  busy = false
336
380
  const turnId = deriveTurnId('chatBusy', null, 700)!
337
381
  const adoption = h.signal.tryAdopt(turnId)
338
382
  expect(adoption).not.toBeNull()
339
383
  expect(adoption!.activityMessageId).toBe(resolvedId)
340
- expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
384
+ expect(h.deletedIds).toEqual([]) // adopted, never reaped as orphan
385
+ expect(h.reinjected).toEqual([])
341
386
  expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
342
387
  expect(h.signal.pendingCount()).toBe(0)
343
388
  })
@@ -345,7 +390,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
345
390
  // The deferred reap is not a leak: once claude goes idle and the handback has
346
391
  // STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
347
392
  // reap finalizes honestly — the degenerate case the reap exists for.
348
- it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
393
+ it('recovers a genuine orphan once claude goes idle, even after deferring while busy', async () => {
349
394
  let busy = true
350
395
  const h = makeHarness({ isClaudeBusy: () => busy })
351
396
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
@@ -354,19 +399,169 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
354
399
 
355
400
  // Busy: the first reap defers.
356
401
  await h.sched.advance(30_000)
357
- expect(h.finalizedIds).toEqual([])
402
+ expect(h.deletedIds).toEqual([])
403
+ expect(h.reinjected).toEqual([])
358
404
  expect(h.signal.pendingCount()).toBe(1)
359
405
 
360
406
  // Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
361
- // The re-armed reap now finalizes the genuine orphan.
407
+ // The re-armed reap now recovers the genuine orphan: delete + re-inject.
362
408
  busy = false
363
409
  await h.sched.advance(30_000)
364
- expect(h.finalizedIds).toEqual([resolvedId])
410
+ expect(h.deletedIds).toEqual([resolvedId])
411
+ expect(h.reinjected).toHaveLength(1)
365
412
  expect(h.typing.activeCount()).toBe(0)
366
413
  expect(h.records.size).toBe(0)
367
414
  expect(h.signal.pendingCount()).toBe(0)
368
415
  })
369
416
 
417
+ // ── #4027 coverage gap: delivered-but-not-yet-streaming is NOT an orphan ──
418
+ // A handback that WAS delivered into claude's queue but whose adopting turn
419
+ // has not started streaming yet reads as idle (`isClaudeBusy()` false) + un-
420
+ // adopted — which #4027 still finalized as a false "needs a nudge" card. The
421
+ // `deliveredAwaitingTurnStart` gate DEFERS the reap for the whole delivered
422
+ // TTL. This test FAILS on revert of the gate: without it the reap fires at the
423
+ // 30 s adopt timeout (idle, un-adopted) and reaps a healthy delivery.
424
+ it('does NOT reap a delivered-but-not-yet-streaming handback within the delivered TTL', async () => {
425
+ // Real delivered TTL, no busy signal — the ONLY thing that can defer the
426
+ // reap here is the delivered-window gate.
427
+ const h = makeHarness({ deliveredTtlMs: 300_000 })
428
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDeliv', messageId: 800 }))
429
+ await h.sched.advance(700)
430
+ const resolvedId = await h.openCard.mock.results[0]!.value
431
+ expect(h.records.size).toBe(1)
432
+
433
+ // Well past the 30 s adopt timeout but comfortably within the 5 min TTL: the
434
+ // reap must DEFER every tick — no delete, no re-inject, entry still live,
435
+ // typing still lit. (Pre-fix this fired a false orphan at 30 s.)
436
+ await h.sched.advance(120_000)
437
+ expect(h.deletedIds).toEqual([])
438
+ expect(h.reinjected).toEqual([])
439
+ expect(h.escalations).toEqual([])
440
+ expect(h.signal.pendingCount()).toBe(1)
441
+ expect(h.typing.activeCount()).toBe(1)
442
+
443
+ // The turn finally starts streaming and adopts the SAME card — an edit, not
444
+ // an orphan reap, and no double-send.
445
+ const turnId = deriveTurnId('chatDeliv', null, 800)!
446
+ const adoption = h.signal.tryAdopt(turnId)
447
+ expect(adoption).not.toBeNull()
448
+ expect(adoption!.activityMessageId).toBe(resolvedId)
449
+ expect(h.deletedIds).toEqual([])
450
+ expect(h.reinjected).toEqual([])
451
+ expect(h.openCard).toHaveBeenCalledTimes(1)
452
+ expect(h.signal.pendingCount()).toBe(0)
453
+ })
454
+
455
+ it('reaps a delivered handback ONCE the delivered TTL elapses (deterministic recovery, still no nudge)', async () => {
456
+ const h = makeHarness({ deliveredTtlMs: 300_000 })
457
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatStuck', messageId: 801 }))
458
+ await h.sched.advance(700)
459
+ const resolvedId = await h.openCard.mock.results[0]!.value
460
+
461
+ // Within the TTL: deferred.
462
+ await h.sched.advance(120_000)
463
+ expect(h.deletedIds).toEqual([])
464
+
465
+ // Past the TTL a truly-stuck delivery finally reaps into recovery rather than
466
+ // hanging forever — delete + re-inject, never a nudge card.
467
+ await h.sched.advance(210_000)
468
+ expect(h.deletedIds).toEqual([resolvedId])
469
+ expect(h.reinjected).toHaveLength(1)
470
+ expect(h.escalations).toEqual([])
471
+ expect(h.signal.pendingCount()).toBe(0)
472
+ })
473
+
474
+ // ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
475
+ // A genuine orphan whose re-injections keep coming back orphaned must not loop
476
+ // forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it
477
+ // escalates to fleet-health telemetry (the `escalateOrphan` sink). The retry
478
+ // counter rides `HANDBACK_REINJECT_COUNT_META_KEY` on the inbound across the
479
+ // buffer round trip, so the cap survives the drop-and-recreate of the entry.
480
+ it('re-injects a persistently-orphaned handback up to the cap, then escalates (never a nudge)', async () => {
481
+ const sched = makeScheduler()
482
+ const sendChatAction = vi.fn<(c: string, t: number | null) => void>()
483
+ const typing = createTurnTypingLoop({
484
+ sendChatAction,
485
+ chatKey: (c, t) => chatKey(c, t),
486
+ refreshMs: 4000,
487
+ })
488
+ let msgSeq = 5000
489
+ const deletedIds: number[] = []
490
+ const reinjectedCounts: (string | undefined)[] = []
491
+ const escalations: HandbackOrphanEscalation[] = []
492
+ const records = new Map<string, PreTurnCardRecord>()
493
+
494
+ // eslint-disable-next-line prefer-const
495
+ let signal: ReturnType<typeof createHandbackPreturnSignal>
496
+ signal = createHandbackPreturnSignal({
497
+ chatKey: (c, t) => chatKey(c, t),
498
+ deriveTurnId,
499
+ startTypingLoop: (c, t) => typing.start(c, t),
500
+ stopTypingLoop: (c, t) => typing.stop(c, t),
501
+ openCard: async () => ++msgSeq,
502
+ deleteCard: (r) => {
503
+ deletedIds.push(r.activityMessageId)
504
+ },
505
+ // Simulate the buffer round trip: a re-injected orphan comes straight back
506
+ // through the release chokepoint carrying its stamped retry counter.
507
+ reinjectHandback: (m) => {
508
+ reinjectedCounts.push(m.meta[HANDBACK_REINJECT_COUNT_META_KEY])
509
+ signal.noteHandbackRelease(m)
510
+ },
511
+ escalateOrphan: (e) => {
512
+ escalations.push(e)
513
+ },
514
+ writeCardRecord: (r) => {
515
+ records.set(r.turnKey, r)
516
+ },
517
+ clearCardRecord: (turnKey, id) => {
518
+ const cur = records.get(turnKey)
519
+ if (cur != null && cur.activityMessageId === id) records.delete(turnKey)
520
+ },
521
+ now: sched.now,
522
+ setTimer: sched.setTimer,
523
+ clearTimer: sched.clearTimer,
524
+ debounceMs: 700,
525
+ adoptTimeoutMs: 30_000,
526
+ deliveredTtlMs: 0, // no delivered-window deferral: exercise the retry cap directly
527
+ maxReinjects: 2,
528
+ })
529
+
530
+ try {
531
+ signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCap', messageId: 802 }))
532
+ // Drive several reap cycles: each orphan deletes its card, re-injects (up
533
+ // to the cap), which re-arms the seam via the simulated round trip.
534
+ await sched.advance(200_000)
535
+
536
+ // Exactly 2 re-injections, stamped 1 then 2, then a single escalation.
537
+ expect(reinjectedCounts).toEqual(['1', '2'])
538
+ expect(escalations).toHaveLength(1)
539
+ expect(escalations[0]!.reinjectCount).toBe(2)
540
+ // One card deleted per orphan cycle (initial + 2 re-injected), never edited
541
+ // to a "needs a nudge" string.
542
+ expect(deletedIds).toHaveLength(3)
543
+ expect(signal.pendingCount()).toBe(0)
544
+ } finally {
545
+ typing.stopAll()
546
+ }
547
+ })
548
+
549
+ // ── the user-facing "needs a nudge" string is GONE from the gateway source ──
550
+ // Part A of the fix removed the operator-facing card text entirely; the orphan
551
+ // path now deletes + re-injects. This is a durable regression guard: it FAILS
552
+ // if the string is reintroduced anywhere in the gateway source.
553
+ it('the "may need a nudge" user-facing string is absent from the gateway source', () => {
554
+ const gatewaySrc = readFileSync(
555
+ new URL('../gateway/gateway.ts', import.meta.url),
556
+ 'utf8',
557
+ )
558
+ // The literal user-facing phrase must not appear as a rendered string. (The
559
+ // only surviving mentions are historical CODE COMMENTS explaining the removal
560
+ // — assert the load-bearing card fragment is gone from any send/edit call.)
561
+ expect(gatewaySrc).not.toContain('it may need a nudge')
562
+ expect(gatewaySrc).not.toContain('HANDBACK_PRETURN_ORPHAN_HTML')
563
+ })
564
+
370
565
  it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
371
566
  const h = makeHarness()
372
567
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
@@ -154,6 +154,9 @@ export function makeLivenessFixture(
154
154
  purgeReactionTracking: () => {},
155
155
  getPendingInboundBuffer: () => ({ drain: () => [] }),
156
156
  trackRedeliveredInbound: () => {},
157
+ // Default: no parked store wired (returns 0). Tests exercising the parked
158
+ // drain override this with the real `drainParkedTurnStartsForChat`.
159
+ drainParkedTurnStarts: (_chatId: string, _threadId: number | null) => 0,
157
160
  closeActivityLane: () => {},
158
161
  closeProgressLane: () => {},
159
162
  hangRestart: null,
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { renderBootCard } from '../gateway/boot-card.js'
6
+ import { renderUpdateOutcomeLine } from '../gateway/update-announce.js'
7
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
8
+
9
+ /**
10
+ * #4115 — ONE indent idiom in the repo, and it is the one that actually
11
+ * renders.
12
+ *
13
+ * #3668 fixed the status card's nested lines by moving `NESTED_PREFIX` off
14
+ * ASCII spaces onto U+2800 BRAILLE PATTERN BLANK: card bodies reach Telegram
15
+ * as GFM markdown parsed SERVER-SIDE, and that parser left-trims a leading
16
+ * whitespace run off a content line, so an ASCII (or U+00A0) indent is dropped
17
+ * on the wire and the line renders FLAT. Live-verified on a phone 2026-07-26 —
18
+ * the record is on `WORKER_STEP_INDENT` in `status-no-truncate.ts`.
19
+ *
20
+ * The boot card and the update-announce line were NOT migrated by that PR, so
21
+ * they kept shipping the collapsed non-indent to the user. These tests assert
22
+ * what RENDERS (the leading run survives a server-side left-trim), not merely
23
+ * that a constant was referenced — a byte-equality assertion against the old
24
+ * literal is exactly what let the pre-#3668 indent ship green and inert.
25
+ */
26
+
27
+ /** Every rule a server-side trimmer could plausibly use. */
28
+ const isTrimmableWhitespace = (ch: string): boolean =>
29
+ /\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
30
+
31
+ /**
32
+ * The assertion that ties to rendering: after a leading-whitespace left-trim
33
+ * of the kind Telegram applies, the line STILL carries its full indent, and it
34
+ * does not lead with either character already proven flat on a phone.
35
+ */
36
+ function expectSurvivesLeftTrim(line: string): void {
37
+ expect(line.startsWith(NESTED_PREFIX)).toBe(true)
38
+ expect(
39
+ line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX),
40
+ `nested line "${line.slice(0, 40)}…" loses its indent to a leading-whitespace trim — ` +
41
+ `it renders FLAT on a phone (#3668/#4115). Use NESTED_PREFIX.`,
42
+ ).toBe(true)
43
+ expect(line.startsWith(' ')).toBe(false)
44
+ expect(line.startsWith(' ')).toBe(false)
45
+ const indent = [...line.slice(0, line.indexOf('↳'))]
46
+ expect(indent.length).toBeGreaterThan(0)
47
+ for (const ch of indent) expect(isTrimmableWhitespace(ch)).toBe(false)
48
+ }
49
+
50
+ /** Card lines with the stack's own break chrome removed. */
51
+ function linesOf(card: string): string[] {
52
+ return card.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
53
+ }
54
+
55
+ describe('boot card nested lines render as a real indent (#4115)', () => {
56
+ it("a probe's next-step continuation line survives a server-side left-trim", () => {
57
+ // Fail-before: this row was `' ↳ ' + nextStep` — four ASCII spaces, all
58
+ // of them trimmed, so the line rendered flush against its probe row.
59
+ const out = renderBootCard({
60
+ agentName: 'lawgpt',
61
+ version: 'v0.7.16',
62
+ probes: {
63
+ skills: {
64
+ status: 'degraded',
65
+ label: 'Skills',
66
+ detail: '10/10 dangling: a, b, c +7 more',
67
+ nextStep: 'Run `switchroom agent reconcile lawgpt` to rebuild symlinks',
68
+ },
69
+ },
70
+ })
71
+ const nested = linesOf(out).filter((l) => l.includes('↳'))
72
+ expect(nested.length).toBe(1)
73
+ expectSurvivesLeftTrim(nested[0])
74
+ })
75
+
76
+ it('the crash row tail-logs continuation line survives a server-side left-trim', () => {
77
+ const out = renderBootCard({
78
+ agentName: 'lawgpt',
79
+ version: 'v0.7.16',
80
+ restartReason: 'crash',
81
+ restartAgeMs: 6_100,
82
+ })
83
+ const nested = linesOf(out).filter((l) => l.includes('↳'))
84
+ expect(nested.length).toBe(1)
85
+ expect(nested[0]).toContain('Tail logs:')
86
+ expectSurvivesLeftTrim(nested[0])
87
+ })
88
+ })
89
+
90
+ describe('update-announce recovery line renders as a real indent (#4115)', () => {
91
+ it('the failure card\'s recovery continuation survives a server-side left-trim', () => {
92
+ // Fail-before: the recovery row was `' ↳ Recovery: …'`.
93
+ const line = renderUpdateOutcomeLine({
94
+ ts: '2026-05-17T11:59:00.000Z',
95
+ op: 'update_apply',
96
+ caller: { kind: 'operator' },
97
+ request_id: 'req-2',
98
+ result: 'error',
99
+ exit_code: 1,
100
+ duration_ms: 100,
101
+ phase: 'terminal',
102
+ stderr_tail: 'compose pull failed: registry timeout',
103
+ install_context: { install_type: 'binary', detected_at: '2026-05-17T11:00:00Z' },
104
+ })
105
+ const nested = linesOf(line).filter((l) => l.includes('↳'))
106
+ expect(nested.length).toBe(1)
107
+ expect(nested[0]).toContain('Recovery:')
108
+ expectSurvivesLeftTrim(nested[0])
109
+ })
110
+ })
111
+
112
+ describe('GUARD: no ASCII-space nested indent may reappear in the source (#4115)', () => {
113
+ // A run of ASCII spaces/tabs immediately before the `↳` nesting glyph — the
114
+ // exact shape that renders flat. The one exported constant is the only legal
115
+ // way to indent a nested card line.
116
+ const ASCII_INDENT_BEFORE_ARROW = /[ \t]{2,}↳/
117
+
118
+ const here = fileURLToPath(import.meta.url)
119
+ const repoRoot = join(here, '..', '..', '..')
120
+ const ROOTS = ['telegram-plugin', 'src']
121
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage'])
122
+
123
+ function walk(dir: string, out: string[]): string[] {
124
+ for (const name of readdirSync(dir)) {
125
+ if (SKIP_DIRS.has(name)) continue
126
+ const p = join(dir, name)
127
+ if (statSync(p).isDirectory()) walk(p, out)
128
+ else if (p.endsWith('.ts') && p !== here) out.push(p)
129
+ }
130
+ return out
131
+ }
132
+
133
+ it('no source file hardcodes an ASCII-indent-plus-↳ nested line', () => {
134
+ const offenders: string[] = []
135
+ for (const root of ROOTS) {
136
+ for (const file of walk(join(repoRoot, root), [])) {
137
+ const text = readFileSync(file, 'utf8')
138
+ text.split('\n').forEach((line, i) => {
139
+ if (ASCII_INDENT_BEFORE_ARROW.test(line)) {
140
+ offenders.push(`${relative(repoRoot, file)}:${i + 1}: ${line.trim().slice(0, 100)}`)
141
+ }
142
+ })
143
+ }
144
+ }
145
+ expect(
146
+ offenders,
147
+ `Hardcoded ASCII indent before a '↳' nesting glyph. Telegram left-trims a leading ` +
148
+ `ASCII-whitespace run off a content line, so this renders FLAT on a phone (#3668/#4115). ` +
149
+ `Import NESTED_PREFIX from status-no-truncate.js instead:\n ${offenders.join('\n ')}`,
150
+ ).toEqual([])
151
+ })
152
+
153
+ it('the guard actually fires on the shape it is guarding against', () => {
154
+ // A guard that cannot fail is not a guard: pin the detector on the exact
155
+ // literal the two migrated call sites used to carry.
156
+ expect(ASCII_INDENT_BEFORE_ARROW.test("degradedRows.push(` ↳ ${next}`)")).toBe(true)
157
+ expect(ASCII_INDENT_BEFORE_ARROW.test(' ↳ Recovery: reinstall')).toBe(true)
158
+ // …and stays quiet on the legal idiom and on prose that merely mentions ↳.
159
+ expect(ASCII_INDENT_BEFORE_ARROW.test(`${NESTED_PREFIX}Recovery: reinstall`)).toBe(false)
160
+ expect(ASCII_INDENT_BEFORE_ARROW.test('the nested ↳ block is windowed')).toBe(false)
161
+ })
162
+ })