switchroom 0.17.10 → 0.18.6

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 (149) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +29 -2
  3. package/dist/auth-broker/index.js +6163 -152
  4. package/dist/cli/notion-write-pretool.mjs +31 -3
  5. package/dist/cli/switchroom.js +695 -526
  6. package/dist/host-control/main.js +6184 -173
  7. package/dist/vault/approvals/kernel-server.js +5893 -165
  8. package/dist/vault/broker/server.js +6666 -921
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +2 -2
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/switchroom-release/SKILL.md +78 -0
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -25
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +740 -106
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  25. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  27. package/telegram-plugin/operator-events.ts +21 -0
  28. package/telegram-plugin/package.json +6 -0
  29. package/telegram-plugin/pending-work-progress.ts +42 -7
  30. package/telegram-plugin/quota-bar-format.ts +360 -0
  31. package/telegram-plugin/quota-watch.ts +4 -6
  32. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  33. package/telegram-plugin/registry/turns-schema.ts +78 -0
  34. package/telegram-plugin/render/ir.ts +209 -0
  35. package/telegram-plugin/render/parse.ts +363 -0
  36. package/telegram-plugin/render/render.ts +440 -0
  37. package/telegram-plugin/render/rich-render.ts +72 -0
  38. package/telegram-plugin/stream-controller.ts +14 -3
  39. package/telegram-plugin/subagent-watcher.ts +27 -9
  40. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  41. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  42. package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
  43. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  44. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  45. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  46. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  47. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  48. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  49. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  50. package/telegram-plugin/tests/model-command.test.ts +193 -16
  51. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  52. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  53. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  54. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  55. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  57. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  58. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  59. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  60. package/telegram-plugin/tests/render/render.test.ts +436 -0
  61. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  62. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  63. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  64. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  65. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  66. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  67. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  68. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  69. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  70. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  71. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  72. package/telegram-plugin/worker-activity-feed.ts +220 -15
  73. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  74. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  75. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  76. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  78. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  79. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
  80. package/profiles/default/CLAUDE.md +0 -116
  81. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  82. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  126. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -40,7 +40,12 @@ import { describe, it, expect } from 'vitest'
40
40
  import * as realFs from 'fs'
41
41
  import { startSubagentWatcher } from '../subagent-watcher.js'
42
42
  import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
43
- import { clipNarrative, appendActivityLabel } from '../tool-activity-summary.js'
43
+ import {
44
+ clipNarrative,
45
+ appendActivityLabel,
46
+ renderActivityFeedWithNested,
47
+ formatStepSuffix,
48
+ } from '../tool-activity-summary.js'
44
49
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
45
50
  import {
46
51
  createWorkerActivityFeed,
@@ -249,6 +254,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
249
254
  finalAnswerDeliveredAt: currentTurn!.finalAnswerDeliveredAt,
250
255
  now: currentTime + 5, // a heartbeat tick moments after the stamp
251
256
  staleCapMs: 30_000,
257
+ stillDispatched: false,
252
258
  })
253
259
  expect(verdictInWindow).toBe('emit')
254
260
  })
@@ -262,6 +268,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
262
268
  finalAnswerDeliveredAt: 1000,
263
269
  now: 50_000,
264
270
  staleCapMs: 30_000,
271
+ stillDispatched: false,
265
272
  })
266
273
  expect(verdict).toBe('idle')
267
274
  })
@@ -273,6 +280,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
273
280
  finalAnswerDeliveredAt: 1000,
274
281
  now: 1500,
275
282
  staleCapMs: 30_000,
283
+ stillDispatched: false,
276
284
  }),
277
285
  ).toBe('idle')
278
286
  })
@@ -310,9 +318,14 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
310
318
 
311
319
  // The gateway's feedHeartbeatTick post-answer entry, reduced to its decision:
312
320
  // `if (turn == null) return` (no-turn), else the REAL evaluatePostAnswerLiveness.
321
+ // `stillDispatched` defaults to false (a purely-background worker, no
322
+ // foreground `turn.foregroundSubAgents` tracking) so these existing tests
323
+ // keep proving the ORIGINAL staleness-cap behaviour is preserved for that
324
+ // case; a dedicated Fix-3 describe block below covers `stillDispatched: true`.
313
325
  function heartbeatVerdict(
314
326
  currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
315
327
  now: number,
328
+ stillDispatched = false,
316
329
  ): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
317
330
  const turn = currentTurn
318
331
  if (turn == null) return 'no-turn' // gateway: `if (turn == null) return`
@@ -322,6 +335,7 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
322
335
  finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
323
336
  now,
324
337
  staleCapMs: 30_000,
338
+ stillDispatched,
325
339
  })
326
340
  }
327
341
 
@@ -377,6 +391,146 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
377
391
  })
378
392
  })
379
393
 
394
+ // ─── Fix 3 — sub-agent-delegation freeze (operator-confirmed: card frozen on
395
+ // "Running a command" for 41s / 3m12s while a sub-agent ran underneath) ──
396
+
397
+ describe('Fix 3: a still-tracked foreground sub-agent bypasses the staleness cap (no mid-delegation freeze)', () => {
398
+ /**
399
+ * Reproduces the EXACT scenario the operator's 3 screenshots showed: a
400
+ * foreground `Task`/`Agent` dispatch is still outstanding (one long silent
401
+ * step — no new watcher narrative tick), so `subagentActivityAt` stops
402
+ * advancing, yet the worker has NOT reported finished. Before Fix 3 the
403
+ * staleness cap could not tell this apart from genuine completion and froze
404
+ * the card after 30s regardless. `stillDispatched: true` — sourced from
405
+ * `turn.foregroundSubAgents.size > 0` at the real call site — is the
406
+ * positive signal that closes the gap.
407
+ */
408
+
409
+ // Local copy of the gateway's post-answer decision (same as the "Fix 2 /
410
+ // concern 2" describe block above — duplicated here rather than hoisted to
411
+ // module scope so each describe stays self-contained/readable).
412
+ function heartbeatVerdict(
413
+ currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
414
+ now: number,
415
+ stillDispatched = false,
416
+ ): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
417
+ const turn = currentTurn
418
+ if (turn == null) return 'no-turn'
419
+ if (!turn.finalAnswerDelivered) return 'pre-answer'
420
+ return evaluatePostAnswerLiveness({
421
+ subagentActivityAt: turn.subagentActivityAt,
422
+ finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
423
+ now,
424
+ staleCapMs: 30_000,
425
+ stillDispatched,
426
+ })
427
+ }
428
+
429
+ it('long silent single step (41s, no new narrative) — bypasses the cap while still dispatched', () => {
430
+ const turn = {
431
+ finalAnswerDelivered: true,
432
+ finalAnswerDeliveredAt: 1000,
433
+ subagentActivityAt: 2000 as number | undefined,
434
+ }
435
+ const currentTurn: typeof turn | null = turn
436
+ // 41s after the last (and only) narrative advance — well past the 30s cap.
437
+ // Legacy (stillDispatched: false) behaviour would already be 'stale' here
438
+ // (proven by the concern-3 test above); with a tracked foreground worker
439
+ // it must stay 'emit' so the card keeps climbing instead of freezing.
440
+ expect(heartbeatVerdict(currentTurn, 2000 + 41_000, /* stillDispatched */ true)).toBe('emit')
441
+ })
442
+
443
+ it('long silent single step (3m12s) — still bypasses the cap while still dispatched', () => {
444
+ const turn = {
445
+ finalAnswerDelivered: true,
446
+ finalAnswerDeliveredAt: 1000,
447
+ subagentActivityAt: 2000 as number | undefined,
448
+ }
449
+ const currentTurn: typeof turn | null = turn
450
+ const threeMinTwelveSecMs = 3 * 60_000 + 12_000
451
+ expect(heartbeatVerdict(currentTurn, 2000 + threeMinTwelveSecMs, true)).toBe('emit')
452
+ })
453
+
454
+ it('once the foreground sub-agent is no longer tracked (finished), the cap re-applies normally', () => {
455
+ // The gateway removes the agentId from `turn.foregroundSubAgents` when it
456
+ // finishes (gateway.ts ~27032/27037) — `stillDispatched` then reads false
457
+ // again and the ORIGINAL runaway-climb protection re-engages exactly as
458
+ // before Fix 3. This is the adversarial check that Fix 3 doesn't remove
459
+ // the cap's protection outright, only bypasses it while genuinely unknown.
460
+ const turn = {
461
+ finalAnswerDelivered: true,
462
+ finalAnswerDeliveredAt: 1000,
463
+ subagentActivityAt: 2000 as number | undefined,
464
+ }
465
+ const currentTurn: typeof turn | null = turn
466
+ expect(heartbeatVerdict(currentTurn, 2000 + 41_000, false)).toBe('stale')
467
+ })
468
+
469
+ it('idle-gap suppression still wins over stillDispatched (no watcher activity ever ⇒ silent, reply-is-last preserved)', () => {
470
+ // Adversarial check: a turn that dispatched a foreground sub-agent but has
471
+ // had NO watcher tick at all since the answer (subagentActivityAt
472
+ // undefined) must stay 'idle', never 'emit', even if stillDispatched is
473
+ // true — a still-registered worker with zero ticks yet is not the same as
474
+ // one whose last tick went stale. This preserves the reply-is-last
475
+ // invariant for a turn that answered and dispatched but hasn't surfaced
476
+ // any post-answer step yet.
477
+ const turn = {
478
+ finalAnswerDelivered: true,
479
+ finalAnswerDeliveredAt: 1000,
480
+ subagentActivityAt: undefined as number | undefined,
481
+ }
482
+ const currentTurn: typeof turn | null = turn
483
+ expect(heartbeatVerdict(currentTurn, 50_000, true)).toBe('idle')
484
+ })
485
+
486
+ it('end-to-end: the RENDERED card text keeps changing across real heartbeat ticks while a foreground sub-agent is silently working (not just the verdict function)', () => {
487
+ // Full simulated turn: dispatch a foreground Task, one narrative tick
488
+ // lands, then NOTHING further for over three minutes (one long silent
489
+ // Bash inside the sub-agent) — the exact operator-reported shape. Drives
490
+ // the REAL `evaluatePostAnswerLiveness` verdict AND the REAL
491
+ // `renderActivityFeedWithNested`/`composeTurnActivity`-shaped render at
492
+ // each tick, asserting the VISIBLE text (not an internal label) actually
493
+ // climbs instead of freezing.
494
+ const turn = {
495
+ finalAnswerDelivered: true,
496
+ finalAnswerDeliveredAt: 1_000,
497
+ subagentActivityAt: 2_000 as number | undefined,
498
+ mirrorLines: ['Delegating to a sub-agent'],
499
+ foregroundSubAgents: new Map<string, string[]>([['agent-1', ['Running a command']]]),
500
+ }
501
+
502
+ function renderCardAt(now: number): string | null {
503
+ const verdict = evaluatePostAnswerLiveness({
504
+ subagentActivityAt: turn.subagentActivityAt,
505
+ finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
506
+ now,
507
+ staleCapMs: 30_000,
508
+ stillDispatched: turn.foregroundSubAgents.size > 0,
509
+ })
510
+ if (verdict !== 'emit') return null // the pre-Fix-3 freeze point
511
+ const childLines = [...turn.foregroundSubAgents.values()].flat()
512
+ const header = { label: 'Agent', elapsedMs: now - 0, toolCount: 1, state: 'running' as const }
513
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines, false, formatStepSuffix(now - turn.subagentActivityAt!), undefined, header)
514
+ }
515
+
516
+ const tick1 = renderCardAt(2_000 + 6_000) // 6s after the last tick
517
+ const tick2 = renderCardAt(2_000 + 41_000) // 41s — the first operator-reported freeze point
518
+ const tick3 = renderCardAt(2_000 + (3 * 60_000 + 12_000)) // 3m12s — the second
519
+
520
+ expect(tick1).not.toBeNull()
521
+ expect(tick2).not.toBeNull()
522
+ expect(tick3).not.toBeNull()
523
+ // The visible text must actually differ tick-over-tick (climbing elapsed),
524
+ // not be frozen at the same string for minutes.
525
+ expect(tick2).not.toBe(tick1)
526
+ expect(tick3).not.toBe(tick2)
527
+ // And it must contain the growing wall-clock evidence the operator was
528
+ // missing — a live elapsed suffix on the nested step.
529
+ expect(tick2).toContain('41s')
530
+ expect(tick3).toContain('3m12s')
531
+ })
532
+ })
533
+
380
534
  // ─── Fix 2 — concern 2 resolution: the decoupled worker surfaces via the
381
535
  // currentTurn-INDEPENDENT workerActivityFeed (and is bounded) ────────────
382
536
 
@@ -96,6 +96,25 @@ describe('classifyRejection — benign Telegram 400s', () => {
96
96
  )
97
97
  expect(classifyRejection(err)).toBe('log_only')
98
98
  })
99
+ it('returns "log_only" for "group chat was upgraded to a supergroup chat" (marko 2026-07-09, recurring 2026-06-07/2026-06-09)', () => {
100
+ // A send targeted a basic-group chat_id that Telegram had since
101
+ // migrated to a supergroup (new -100xxxxxxxxxx id). This crashed the
102
+ // ENTIRE gateway process (every agent/chat) over a single stale
103
+ // cached chat_id — the same root cause recurred three times across a
104
+ // month because the crash-on-leak behavior masked the underlying
105
+ // stale-id bug instead of just logging it. A migrated/invalid
106
+ // destination chat must never take down the whole gateway.
107
+ const err = grammyError(
108
+ 400,
109
+ 'Bad Request: group chat was upgraded to a supergroup chat',
110
+ )
111
+ expect(classifyRejection(err)).toBe('log_only')
112
+ })
113
+
114
+ it('returns "log_only" for "group chat was deactivated"', () => {
115
+ const err = grammyError(400, 'Bad Request: group chat was deactivated')
116
+ expect(classifyRejection(err)).toBe('log_only')
117
+ })
99
118
  })
100
119
 
101
120
  describe('classifyRejection — genuine errors still crash', () => {
@@ -945,6 +945,103 @@ describe('createWorkerActivityFeed — heartbeat', () => {
945
945
  })
946
946
  })
947
947
 
948
+ // ─── Resurrection guard + deferred finalize (review findings #2 & #3) ─────────
949
+ // #3: a late watcher onProgress tick arriving after `finish()` queued its
950
+ // chain must NOT resurrect the handle and paint a fresh running message.
951
+ // #2: a terminal edit that hit a 429 cooldown stages on `pendingFinish` and
952
+ // is re-driven by the heartbeat after cooldown — the card can't get stuck
953
+ // on its last running render. "not modified" / message-gone are success/drop.
954
+ describe('createWorkerActivityFeed — resurrection guard + deferred finalize', () => {
955
+ const drain = () => new Promise((r) => setTimeout(r, 0))
956
+
957
+ it('#3: a late update tick after finish does not resurrect a running card', async () => {
958
+ const bot = makeFakeBot()
959
+ let clock = 10_000
960
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
961
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
962
+ expect(bot.sent).toHaveLength(1)
963
+
964
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done result' }))
965
+ // finish landed a terminal edit and dropped the handle.
966
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
967
+ expect(feed.has('w1')).toBe(false)
968
+
969
+ // Late watcher tick arrives AFTER finish. Pre-fix this would create a
970
+ // fresh handle and paint a new running message on a finalized worker.
971
+ clock = 12_000
972
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'step two' }))
973
+ expect(bot.sent).toHaveLength(1) // no new running message
974
+ expect(feed.has('w1')).toBe(false)
975
+ expect(feed.size).toBe(0)
976
+ })
977
+
978
+ it('#2: a 429 on the finish edit stages pendingFinish; the heartbeat re-drives it after cooldown', async () => {
979
+ const bot = makeFakeBot()
980
+ let clock = 10_000
981
+ const feed = createWorkerActivityFeed({
982
+ bot,
983
+ now: () => clock,
984
+ minEditIntervalMs: 0,
985
+ heartbeatTickMs: 6000,
986
+ setInterval: () => 1,
987
+ clearInterval: () => {},
988
+ })
989
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'running step' }))
990
+ expect(bot.sent).toHaveLength(1)
991
+
992
+ // The terminal edit hits a 429 with a 2s retry_after.
993
+ bot.failNextEditWith = { error_code: 429, parameters: { retry_after: 2 } }
994
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'final result' }))
995
+ await drain()
996
+ // No terminal edit landed yet — the last edit is still the running render.
997
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
998
+
999
+ // Inside the cooldown — a heartbeat tick must NOT retry (would re-429).
1000
+ clock = 11_000
1001
+ feed.heartbeatTick()
1002
+ await drain()
1003
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
1004
+
1005
+ // Past the cooldown (10_000 + 2000 + 500 jitter = 12_500) — the heartbeat
1006
+ // re-drives the deferred finalize and the terminal edit lands.
1007
+ clock = 13_000
1008
+ feed.heartbeatTick()
1009
+ await drain()
1010
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
1011
+ expect(feed.has('w1')).toBe(false) // handle dropped after the terminal edit landed
1012
+ })
1013
+
1014
+ it('#2: "message is not modified" on finish is treated as success (card already correct)', async () => {
1015
+ const bot = makeFakeBot()
1016
+ let clock = 10_000
1017
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1018
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1019
+ expect(bot.sent).toHaveLength(1)
1020
+
1021
+ bot.failNextEditWith = new Error('Bad Request: message is not modified')
1022
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'x' }))
1023
+ await drain()
1024
+ // The handle is dropped — the not-modified outcome is success, no retry.
1025
+ expect(feed.has('w1')).toBe(false)
1026
+ })
1027
+
1028
+ it('#2: a gone message on finish drops the handle silently (no card to finalize)', async () => {
1029
+ const bot = makeFakeBot()
1030
+ const logs: string[] = []
1031
+ let clock = 10_000
1032
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0, log: (m) => logs.push(m) })
1033
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1034
+ expect(bot.sent).toHaveLength(1)
1035
+
1036
+ bot.failNextEditWith = new Error('Bad Request: message to edit not found')
1037
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done' }))
1038
+ await drain()
1039
+ expect(feed.has('w1')).toBe(false)
1040
+ // No scary "finish edit failed" warning for a transport-gone outcome.
1041
+ expect(logs.some((l) => l.includes('finish edit failed'))).toBe(false)
1042
+ })
1043
+ })
1044
+
948
1045
  // ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
949
1046
  // Reproduces the bug where accumulateNarrative's char-budget splice would push
950
1047
  // the oversized line then immediately splice it out, making the narrative empty
@@ -9,14 +9,17 @@
9
9
  * Run with:
10
10
  * bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
11
11
  */
12
- import { describe, it, expect } from "vitest";
12
+ import { describe, it, expect, beforeEach } from "vitest";
13
13
  import {
14
14
  ownedWorktreeCwds,
15
+ __resetIdentityEscalationForTests,
15
16
  type WorktreeOwnershipRecord,
16
17
  } from "../worktree-watch-cwds.js";
17
18
 
18
19
  const idPath = (p: string) => p; // identity realpath for deterministic tests
19
20
 
21
+ beforeEach(() => __resetIdentityEscalationForTests());
22
+
20
23
  describe("ownedWorktreeCwds", () => {
21
24
  const records: WorktreeOwnershipRecord[] = [
22
25
  { path: "/wt/mine-1", ownerAgent: "klanker" },
@@ -25,18 +28,110 @@ describe("ownedWorktreeCwds", () => {
25
28
  { path: "/wt/ownerless" }, // ownerAgent undefined
26
29
  ];
27
30
 
28
- it("returns NOTHING when the agent identity is unset (fail-closed, no #1116 leak)", () => {
31
+ it("returns NOTHING when the agent identity is unset and no durable fallback given (fail-closed, no #1116 leak)", () => {
29
32
  expect(
30
33
  ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
31
34
  ).toEqual([]);
32
35
  });
33
36
 
34
- it("returns NOTHING when the agent identity is empty string", () => {
37
+ it("returns NOTHING when the agent identity is empty string and no durable fallback given", () => {
35
38
  expect(
36
39
  ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
37
40
  ).toEqual([]);
38
41
  });
39
42
 
43
+ // ---- Layer 2: durable, non-env identity fallback (#1116 / #2893) ----
44
+
45
+ it("(a) env SET → ownership resolves exactly as before (fast path unchanged)", () => {
46
+ expect(
47
+ ownedWorktreeCwds({
48
+ self: "klanker",
49
+ agentDir: "/home/x/.switchroom/agents/SHOULD_BE_IGNORED",
50
+ listRecords: () => records,
51
+ realpath: idPath,
52
+ }),
53
+ ).toEqual(["/wt/mine-1", "/wt/mine-2"]);
54
+ });
55
+
56
+ it("(b) env UNSET but agentDir present → identity derived from dir basename, ownership resolves", () => {
57
+ const out = ownedWorktreeCwds({
58
+ self: undefined,
59
+ agentDir: "/home/x/.switchroom/agents/klanker",
60
+ listRecords: () => records,
61
+ realpath: idPath,
62
+ });
63
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
64
+ });
65
+
66
+ it("(b) env EMPTY but agentDir present → same durable derivation", () => {
67
+ const out = ownedWorktreeCwds({
68
+ self: "",
69
+ agentDir: "/home/x/.switchroom/agents/klanker",
70
+ listRecords: () => records,
71
+ realpath: idPath,
72
+ });
73
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
74
+ });
75
+
76
+ it("(b) durable fallback NEVER mis-attributes: a dir for a different agent matches only THAT agent's records, never klanker's", () => {
77
+ // agentDir names 'reggie' → resolves reggie's worktrees, never klanker's
78
+ // or the ownerless ones. A wrong basename fails-closed to [], it can never
79
+ // attribute to the WRONG owner.
80
+ const out = ownedWorktreeCwds({
81
+ self: undefined,
82
+ agentDir: "/home/x/.switchroom/agents/reggie",
83
+ listRecords: () => records,
84
+ realpath: idPath,
85
+ });
86
+ expect(out).toEqual(["/wt/theirs"]);
87
+ expect(out).not.toContain("/wt/ownerless");
88
+ });
89
+
90
+ it("(c) BOTH env and agentDir unavailable → returns [] AND emits an escalated ERROR (no throw, no mis-attribution)", () => {
91
+ const logs: string[] = [];
92
+ const out = ownedWorktreeCwds({
93
+ self: undefined,
94
+ agentDir: undefined,
95
+ listRecords: () => records,
96
+ realpath: idPath,
97
+ log: (m) => logs.push(m),
98
+ });
99
+ expect(out).toEqual([]);
100
+ expect(logs).toHaveLength(1);
101
+ expect(logs[0]).toContain("ERROR");
102
+ expect(logs[0]).toContain("identity resolution FAILED");
103
+ });
104
+
105
+ it("(c) blank agentDir (whitespace) is treated as unavailable → [] + escalated error", () => {
106
+ const logs: string[] = [];
107
+ const out = ownedWorktreeCwds({
108
+ self: "",
109
+ agentDir: " ",
110
+ listRecords: () => records,
111
+ realpath: idPath,
112
+ log: (m) => logs.push(m),
113
+ });
114
+ expect(out).toEqual([]);
115
+ expect(logs).toHaveLength(1);
116
+ expect(logs[0]).toContain("identity resolution FAILED");
117
+ });
118
+
119
+ it("(c) the escalated error is one-shot per process (not re-logged every rescan tick)", () => {
120
+ const logs: string[] = [];
121
+ const call = () =>
122
+ ownedWorktreeCwds({
123
+ self: undefined,
124
+ agentDir: undefined,
125
+ listRecords: () => records,
126
+ realpath: idPath,
127
+ log: (m) => logs.push(m),
128
+ });
129
+ call();
130
+ call();
131
+ call();
132
+ expect(logs).toHaveLength(1);
133
+ });
134
+
40
135
  it("does NOT match ownerless records even when identity is set", () => {
41
136
  const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
42
137
  expect(out).not.toContain("/wt/ownerless");
@@ -227,13 +227,47 @@ export interface PostAnswerLivenessInput {
227
227
  now: number
228
228
  /** Staleness cap in ms; `<= 0` disables the cap. */
229
229
  staleCapMs: number
230
+ /**
231
+ * A POSITIVE, independent signal that a sub-agent dispatch is still known to
232
+ * be outstanding (e.g. `turn.foregroundSubAgents.size > 0` — a foreground
233
+ * `Task`/`Agent` this turn dispatched and has not yet reported finished).
234
+ *
235
+ * ## The gap this closes
236
+ *
237
+ * The staleness cap above was built to stop the card climbing FOREVER once
238
+ * a worker's `onFinish` froze `subagentActivityAt` and nothing further would
239
+ * ever arrive — but the ONLY signal it read (`now - subagentActivityAt`) is
240
+ * identical whether the worker (a) actually finished, or (b) is still
241
+ * genuinely running a SINGLE long silent step (one long Bash call, a slow
242
+ * fetch) that simply hasn't produced a NEW distinguishable watcher tick.
243
+ * Case (b) is exactly the scenario `feed-heartbeat-climb.ts`'s 0-label climb
244
+ * and `worker-activity-feed.ts`'s own heartbeat both exist to handle
245
+ * deterministically elsewhere — this post-answer branch alone lacked that
246
+ * fallback, so it froze the card mid-delegation (the confirmed operator
247
+ * symptom: "Running a command" stuck for 41s / 3m12s while a sub-agent ran
248
+ * underneath).
249
+ *
250
+ * When `stillDispatched` is `true` we have POSITIVE evidence the worker has
251
+ * not reported completion, so the staleness cap is bypassed entirely and the
252
+ * verdict stays `'emit'` — the caller keeps climbing the card deterministically
253
+ * off wall-clock elapsed exactly like the sibling 0-label/worker-feed paths.
254
+ * When `false` (no such tracking available — e.g. a purely-background worker
255
+ * with no foreground registration), the ORIGINAL cap behaviour is preserved
256
+ * unchanged, so the runaway-climb-after-completion protection this cap was
257
+ * built for still applies wherever we have no better signal.
258
+ */
259
+ stillDispatched: boolean
230
260
  }
231
261
 
232
262
  export function evaluatePostAnswerLiveness(input: PostAnswerLivenessInput): PostAnswerLivenessVerdict {
233
- const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs } = input
263
+ const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs, stillDispatched } = input
234
264
  const answeredAt = finalAnswerDeliveredAt ?? 0
235
265
  // idle-gap: nothing surfaced after the answer → silent (reply-is-last preserved).
236
266
  if (subagentActivityAt == null || subagentActivityAt <= answeredAt) return 'idle'
267
+ // A positive "still dispatched" signal overrides the staleness cap — we KNOW
268
+ // the worker hasn't reported done, so a quiet stretch is a long silent step,
269
+ // not completion. Never freeze the card while that's true.
270
+ if (stillDispatched) return 'emit'
237
271
  // staleness cap: the worker's last advance is older than the cap → stop emitting.
238
272
  if (staleCapMs > 0 && now - subagentActivityAt >= staleCapMs) return 'stale'
239
273
  return 'emit'
@@ -56,9 +56,20 @@
56
56
  import { describe, it, expect } from "vitest";
57
57
  import { spinUp } from "../harness.js";
58
58
  import type { ObservedMessage } from "../driver.js";
59
+ import { richRenderEnabled } from "../../render/rich-render.js";
59
60
 
60
61
  const AGENT = "test-harness";
61
62
 
63
+ // The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
64
+ // gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
65
+ // collapsible round-trip proof below only runs when BOTH the driver creds AND
66
+ // the flag are present — the flag must be set in the gateway process under
67
+ // test for the renderer to actually shape the outbound message, so gating the
68
+ // scenario on the same flag keeps it honest (no false green when the wiring
69
+ // isn't live). When the flag is off, this scenario self-skips green exactly
70
+ // like the credential-less case.
71
+ const RICH_RENDER_ON = richRenderEnabled();
72
+
62
73
  // The driver session is the load-bearing credential. Absent it, spinUp()
63
74
  // throws in resolveConfig — so guard at describe level and self-skip green.
64
75
  const HAS_DRIVER_CREDS =
@@ -105,13 +116,18 @@ const SAMPLE_LINES = [
105
116
  * trip on the uat-host (PR #2745 first live run: present set was
106
117
  * `bold, italic, code, strikethrough, text_link, blockquote, pre`).
107
118
  *
108
- * `spoiler` is deliberately NOT hard-asserted: on the live wire the `||…||`
109
- * span did NOT surface as a `spoiler`/`textMarked` entity (the model either
110
- * dropped the syntax or Telegram's chat-message GFM parser doesn't map it the
111
- * way the IV table-of-contents `textMarked` node does). Rather than red the
112
- * whole render gate on a construct the round trip doesn't reliably produce, we
113
- * observe spoiler softly (logged below). The decoder's `textMarked → spoiler`
114
- * mapping is still pinned deterministically by the hosted unit suite.
119
+ * `spoiler` is soft-observed HERE only because of THIS harness's decode path:
120
+ * the `||…||` span did NOT reliably surface as a `spoiler`/`textMarked` entity
121
+ * through the MTProto send→IV→decode round trip the UAT driver uses. This is a
122
+ * harness-decoder limitation, NOT a wire failure: a 2026-07 direct probe of the
123
+ * Bot API `sendRichMessage` endpoint (the actual production send path) confirmed
124
+ * `||spoiler||` DOES parse to a `spoiler` entity and `==highlight==` to `marked`
125
+ * on the live wire — see `reference/rfcs/telegram-native-formatting.md` §6a.
126
+ * Spoiler ships default-on; it stays soft here purely so this MTProto-decode
127
+ * gate doesn't red on its own decode gap. (Underline `__…__`, by contrast, is a
128
+ * genuine wire exclusion — it parses as bold; see §6a.) The decoder's
129
+ * `textMarked → spoiler` mapping is still pinned deterministically by the
130
+ * hosted unit suite.
115
131
  *
116
132
  * Lists and dividers carry no first-class Bot API entity (they render as
117
133
  * bulleted/numbered/rule TEXT), so they are asserted on `reply.text` below.
@@ -252,3 +268,79 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
252
268
  );
253
269
  },
254
270
  );
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Rich-render wiring proof — expandable / collapsible content round-trip.
274
+ //
275
+ // This is the piece the unit suite CANNOT prove: that a REAL markdown reply
276
+ // carrying the Bot API 10.1 expandable-blockquote marker (`**> `) flows
277
+ // through the live send path — parse.ts (marker -> IR `expandable: true`) ->
278
+ // render.ts (IR -> `**> ` markdown) -> renderSafe -> sendRichMessage — and
279
+ // that Telegram actually parses it back to a blockquote entity on the wire.
280
+ //
281
+ // Runs ONLY when the rich-render flag is on in this process (see
282
+ // RICH_RENDER_ON) AND the driver creds are present; self-skips green
283
+ // otherwise, so default CI (flag off) never reds on it.
284
+ // ---------------------------------------------------------------------------
285
+
286
+ const EXP_MARKER = "COLLAPSE9";
287
+ const EXPANDABLE_SAMPLE = [
288
+ `Reply with EXACTLY this and NOTHING else, keeping ${EXP_MARKER} verbatim:`,
289
+ "",
290
+ `${EXP_MARKER}: here is a collapsible section.`,
291
+ "",
292
+ "**> first hidden line of the expandable quote",
293
+ "> second hidden line",
294
+ "> third hidden line",
295
+ ].join("\n");
296
+
297
+ (HAS_DRIVER_CREDS && RICH_RENDER_ON ? describe : describe.skip)(
298
+ "uat: expandable/collapsible content round-trips through the live renderer",
299
+ () => {
300
+ it(
301
+ "a `**>` reply parses -> renders -> sends -> decodes as a blockquote entity",
302
+ async () => {
303
+ const sc = await spinUp({ agent: AGENT });
304
+ try {
305
+ await sc.sendDM(EXPANDABLE_SAMPLE);
306
+
307
+ const reply = await sc.expectMessage(
308
+ (m: ObservedMessage) =>
309
+ m.text.includes(EXP_MARKER) || m.text === "\x01",
310
+ { from: "bot", timeout: 90_000 },
311
+ );
312
+
313
+ // Decode regression gate (same as the primary scenario).
314
+ expect(
315
+ reply.text,
316
+ "expandable reply decoded as the \\x01 unsupported-media sentinel",
317
+ ).not.toBe("\x01");
318
+ expect(reply.text).toContain(EXP_MARKER);
319
+
320
+ // The collapsible construct must survive as a real blockquote entity
321
+ // on the wire — proof the parse->render->send path produced valid
322
+ // Bot API 10.1 markdown, not stray `**>` literal text.
323
+ const present = kindsPresent(reply);
324
+ expect(
325
+ [...present],
326
+ `expandable quote did not decode as a blockquote entity ` +
327
+ `(present kinds: ${[...present].join(", ")})`,
328
+ ).toContain("blockquote");
329
+
330
+ // The quoted body text must round-trip (content never lost).
331
+ expect(reply.text).toContain("first hidden line");
332
+
333
+ console.info(
334
+ "[uat] expandable round-trip entity structure: " +
335
+ JSON.stringify(
336
+ reply.entities.map((e) => ({ kind: e.kind, text: e.text })),
337
+ ),
338
+ );
339
+ } finally {
340
+ await sc.tearDown();
341
+ }
342
+ },
343
+ 120_000,
344
+ );
345
+ },
346
+ );