switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Outcome-based regression test for the deterministic silent-turn card climb.
3
+ *
4
+ * Phase 4(b) of `reference/rfcs/deterministic-turn-liveness.md`; serves
5
+ * `reference/jobs/know-what-my-agent-is-doing.md` (a busy-but-silent turn must
6
+ * visibly escalate, never freeze).
7
+ *
8
+ * ## What is REAL here, and what is a spy (no overclaim)
9
+ *
10
+ * The gateway is a start-on-import IIFE that cannot be instantiated in-process
11
+ * (the documented constraint behind every `*-wiring` structural test in this
12
+ * suite). So the shipped 0-label tick body was EXTRACTED into
13
+ * `feed-heartbeat-climb.ts` (`runSilentTurnHeartbeatTick`) and the gateway's
14
+ * `feedHeartbeatTick` calls it with its real deps — meaning the function under
15
+ * test IS the production decision logic, byte-for-byte, not a hand-copied
16
+ * mirror. What this test drives:
17
+ *
18
+ * - REAL: `runSilentTurnHeartbeatTick` + `silentTurnClimbRender` — the shipped
19
+ * guard placement (0-label check, open-vs-climb split, stale-guard, null
20
+ * render), the gate SEQUENCE (cardDrainGate → mayDrain → openOrEditCard →
21
+ * drain), and the rendered card bodies.
22
+ * - SPY: the injected deps. The `drain` seam is where the gateway's
23
+ * `drainActivitySummary` → `robustApiCall(bot.api.sendMessage /
24
+ * bot.api.editMessageText)` lives in production; the spy implements the
25
+ * drain's transport contract (send when no card id, edit once set) and
26
+ * records every call with a timestamp. The real `drainActivitySummary` /
27
+ * grammY transport is NOT executed here — the gateway wiring of the real
28
+ * deps (and the real drain call) is pinned structurally by
29
+ * `tests/feed-heartbeat-liveness-open.test.ts`, and end-to-end by the UAT
30
+ * liveness scenarios.
31
+ *
32
+ * ## What it asserts
33
+ *
34
+ * 1. A ~40s silent tool call produces >=4 card EDITS with a CLIMBING elapsed —
35
+ * the card never freezes. (Acceptance criterion of the RFC.)
36
+ * 2. Every climb mutation is an EDIT of the already-open card — never a new
37
+ * send — so no mid-turn mutation can push-notify the device.
38
+ * 3. Each climb edit passes through the full gate sequence in order:
39
+ * setPendingRender → cardDrainGate → mayDrain → openOrEditCard → drain
40
+ * (mis-wiring the guards inside the tick body fails this).
41
+ * 4. `mayDrain === false` (drain in flight) suppresses the edit — the
42
+ * single-flight guard is honoured.
43
+ * 5. The climb yields to model narration: a surfaced tool label
44
+ * (`mirrorLineCount > 0`) returns `false` (labelled branch owns the tick)
45
+ * and emits nothing.
46
+ * 6. The stale-guard: a turn younger than `minStaleMs` produces no edit (same
47
+ * symmetry the labelled branch has).
48
+ *
49
+ * ## Why this FAILS on pre-fix code (its acceptance criterion)
50
+ *
51
+ * Pre-fix, the 0-label branch delegated solely to `openLivenessFeedIfDue`,
52
+ * whose WHEN-gate returns false the moment a card is open — an open 0-label
53
+ * card received ZERO edits during a silent tool. Reverting
54
+ * `runSilentTurnHeartbeatTick` to that behaviour (the open-path delegate with
55
+ * no climb) drives this test's edit count to 0, failing assertion 1 — through
56
+ * the same shipped function the gateway calls. Removing the gateway call site
57
+ * instead is caught by the structural pin in
58
+ * `tests/feed-heartbeat-liveness-open.test.ts`.
59
+ */
60
+ import { describe, it, expect } from 'vitest'
61
+ import {
62
+ runSilentTurnHeartbeatTick,
63
+ silentTurnClimbRender,
64
+ type SilentTurnTickDeps,
65
+ } from '../feed-heartbeat-climb.js'
66
+
67
+ // The gateway constants that drive the tick cadence (gateway.ts).
68
+ const FEED_HEARTBEAT_TICK_MS = 6_000
69
+ const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
70
+ const FEED_LIVENESS_OPEN_MS = 1_200
71
+
72
+ /** A recorded transport call with the simulated wall-clock time it happened at. */
73
+ interface TransportCall {
74
+ kind: 'sendMessage' | 'editMessageText'
75
+ at: number
76
+ text: string
77
+ }
78
+
79
+ /** Parse the climbing elapsed (in seconds) out of a rendered card header line
80
+ * (`_6s · 0 tools_`, `_1m05s · 0 tools_`). Returns null if not present. */
81
+ function parseHeaderElapsedSeconds(text: string): number | null {
82
+ const m = text.match(/_((?:(\d+)m)?(\d+)s) · \d+ tools?_/)
83
+ if (m == null) return null
84
+ const minutes = m[2] ? Number(m[2]) : 0
85
+ const seconds = Number(m[3])
86
+ return minutes * 60 + seconds
87
+ }
88
+
89
+ /**
90
+ * Test double for the turn's card surface + injected deps. The `drain` spy
91
+ * implements the drain's transport contract — `sendMessage` (an OPEN, the only
92
+ * mutation that push-notifies) when no card id exists, `editMessageText` (a
93
+ * silent card EDIT) once it does — standing in for `drainActivitySummary` →
94
+ * `robustApiCall(bot.api.*)`. Also records the gate-call ORDER per tick so the
95
+ * shipped sequence is pinned, and simulates the shared OPEN path
96
+ * (`openLivenessFeedIfDue`) opening the placeholder card.
97
+ */
98
+ class TurnHarness {
99
+ calls: TransportCall[] = []
100
+ order: string[] = []
101
+ activityMessageId: number | null = null
102
+ pendingRender: string | null = null
103
+ mayDrainResult = true
104
+ private nextId = 100
105
+ now = 0
106
+
107
+ /** The real gateway wires these exact seams; each spy records its call. */
108
+ deps(openRender: () => string | null): SilentTurnTickDeps {
109
+ return {
110
+ openLivenessFeedIfDue: () => {
111
+ this.order.push('openLivenessFeedIfDue')
112
+ // Mirror of the shared OPEN path's contract: opens once, past its
113
+ // threshold, via a fresh sendMessage.
114
+ if (this.activityMessageId != null) return
115
+ const rendered = openRender()
116
+ if (rendered == null) return
117
+ this.activityMessageId = this.nextId++
118
+ this.calls.push({ kind: 'sendMessage', at: this.now, text: rendered })
119
+ },
120
+ setPendingRender: (rendered) => {
121
+ this.order.push('setPendingRender')
122
+ this.pendingRender = rendered
123
+ },
124
+ cardDrainGate: (run) => {
125
+ this.order.push('cardDrainGate')
126
+ run()
127
+ },
128
+ mayDrain: () => {
129
+ this.order.push('mayDrain')
130
+ return this.mayDrainResult
131
+ },
132
+ openOrEditCard: (apply) => {
133
+ this.order.push('openOrEditCard')
134
+ apply()
135
+ },
136
+ drain: () => {
137
+ this.order.push('drain')
138
+ // drainActivitySummary's transport contract: OPEN when no card id,
139
+ // EDIT in place once set. Here the card is always open (the tick only
140
+ // reaches drain via the climb), so this is always an edit.
141
+ const text = this.pendingRender ?? ''
142
+ if (this.activityMessageId == null) {
143
+ this.activityMessageId = this.nextId++
144
+ this.calls.push({ kind: 'sendMessage', at: this.now, text })
145
+ } else {
146
+ this.calls.push({ kind: 'editMessageText', at: this.now, text })
147
+ }
148
+ },
149
+ }
150
+ }
151
+
152
+ get sends(): TransportCall[] {
153
+ return this.calls.filter((c) => c.kind === 'sendMessage')
154
+ }
155
+ get edits(): TransportCall[] {
156
+ return this.calls.filter((c) => c.kind === 'editMessageText')
157
+ }
158
+ }
159
+
160
+ /** Drive the REAL tick across a silent window, the way the gateway's 6s
161
+ * setInterval does: same view fields, real deps thunks. */
162
+ function runSilentWindow(harness: TurnHarness, turnStart: number, windowMs: number): void {
163
+ const deps = harness.deps(() => 'placeholder')
164
+ for (let now = turnStart; now <= turnStart + windowMs; now += FEED_HEARTBEAT_TICK_MS) {
165
+ harness.now = now
166
+ const handled = runSilentTurnHeartbeatTick(
167
+ {
168
+ mirrorLineCount: 0,
169
+ activityMessageId: harness.activityMessageId,
170
+ labeledToolCount: 0,
171
+ ageMs: now - turnStart,
172
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
173
+ },
174
+ deps,
175
+ )
176
+ expect(handled).toBe(true) // 0-label turn: this branch owns every tick
177
+ }
178
+ }
179
+
180
+ describe('deterministic silent-turn card climb — real tick against a spy transport', () => {
181
+ it('a ~40s silent tool call yields >=4 card edits with climbing elapsed (never freezes)', () => {
182
+ const harness = new TurnHarness()
183
+ const turnStart = 1_000_000 // arbitrary simulated epoch
184
+ // Pre-open the card the way production does (early open ~1.2s in via the
185
+ // shared OPEN path) — the freeze reproduced specifically on an OPEN card.
186
+ harness.now = turnStart + FEED_LIVENESS_OPEN_MS
187
+ harness.deps(() => 'placeholder').openLivenessFeedIfDue()
188
+ expect(harness.activityMessageId).not.toBeNull()
189
+
190
+ runSilentWindow(harness, turnStart, 40_000)
191
+
192
+ // Exactly one OPEN (the pre-opened card) — the climb never sends.
193
+ expect(harness.sends.length).toBe(1)
194
+
195
+ // The acceptance criterion: the card is EDITED at least 4 times across the gap.
196
+ expect(
197
+ harness.edits.length,
198
+ `expected >=4 climbing card edits across a 40s silent tool, got ${harness.edits.length} ` +
199
+ `(pre-fix code freezes the 0-label card and produces 0 edits)`,
200
+ ).toBeGreaterThanOrEqual(4)
201
+
202
+ // The elapsed on each successive edit strictly climbs — no frozen counter.
203
+ const elapsedSeries = harness.edits
204
+ .map((c) => parseHeaderElapsedSeconds(c.text))
205
+ .filter((n): n is number => n != null)
206
+ expect(elapsedSeries.length).toBe(harness.edits.length)
207
+ for (let i = 1; i < elapsedSeries.length; i++) {
208
+ expect(
209
+ elapsedSeries[i],
210
+ `card elapsed must climb: edit[${i}]=${elapsedSeries[i]}s not > edit[${i - 1}]=${elapsedSeries[i - 1]}s`,
211
+ ).toBeGreaterThan(elapsedSeries[i - 1])
212
+ }
213
+
214
+ // Worst-case visible-update gap stays within ~two tick windows (RFC bound).
215
+ const editTimes = [harness.sends[0].at, ...harness.edits.map((c) => c.at)]
216
+ for (let i = 1; i < editTimes.length; i++) {
217
+ expect(editTimes[i] - editTimes[i - 1]).toBeLessThanOrEqual(2 * FEED_HEARTBEAT_TICK_MS)
218
+ }
219
+ })
220
+
221
+ it('every climb mutation is an editMessageText, never a new sendMessage (edits do not push-notify)', () => {
222
+ const harness = new TurnHarness()
223
+ const turnStart = 2_000_000
224
+ harness.now = turnStart + FEED_LIVENESS_OPEN_MS
225
+ harness.deps(() => 'placeholder').openLivenessFeedIfDue()
226
+ runSilentWindow(harness, turnStart, 40_000)
227
+
228
+ expect(harness.calls[0].kind).toBe('sendMessage')
229
+ for (const call of harness.calls.slice(1)) {
230
+ expect(call.kind).toBe('editMessageText')
231
+ }
232
+ })
233
+
234
+ it('each climb edit passes the full shipped gate sequence in order (pendingRender → cardDrainGate → mayDrain → openOrEditCard → drain)', () => {
235
+ const harness = new TurnHarness()
236
+ harness.activityMessageId = 55 // card already open
237
+ harness.now = 12_000
238
+ runSilentTurnHeartbeatTick(
239
+ {
240
+ mirrorLineCount: 0,
241
+ activityMessageId: harness.activityMessageId,
242
+ labeledToolCount: 0,
243
+ ageMs: 12_000,
244
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
245
+ },
246
+ harness.deps(() => null),
247
+ )
248
+ expect(harness.order).toEqual([
249
+ 'setPendingRender',
250
+ 'cardDrainGate',
251
+ 'mayDrain',
252
+ 'openOrEditCard',
253
+ 'drain',
254
+ ])
255
+ })
256
+
257
+ it('a drain already in flight (mayDrain=false) suppresses the edit — single-flight honoured', () => {
258
+ const harness = new TurnHarness()
259
+ harness.activityMessageId = 55
260
+ harness.mayDrainResult = false
261
+ harness.now = 12_000
262
+ runSilentTurnHeartbeatTick(
263
+ {
264
+ mirrorLineCount: 0,
265
+ activityMessageId: 55,
266
+ labeledToolCount: 0,
267
+ ageMs: 12_000,
268
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
269
+ },
270
+ harness.deps(() => null),
271
+ )
272
+ expect(harness.edits.length).toBe(0)
273
+ // The gate was consulted, then the drain was NOT reached.
274
+ expect(harness.order).toContain('mayDrain')
275
+ expect(harness.order).not.toContain('drain')
276
+ })
277
+
278
+ it('the climb yields to model narration (a surfaced tool label hands the tick to the labelled branch)', () => {
279
+ const harness = new TurnHarness()
280
+ harness.activityMessageId = 55
281
+ const handled = runSilentTurnHeartbeatTick(
282
+ {
283
+ mirrorLineCount: 3, // narration / tool labels surfaced
284
+ activityMessageId: 55,
285
+ labeledToolCount: 2,
286
+ ageMs: 30_000,
287
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
288
+ },
289
+ harness.deps(() => null),
290
+ )
291
+ expect(handled).toBe(false) // labelled branch owns the tick
292
+ expect(harness.calls.length).toBe(0)
293
+ expect(harness.order.length).toBe(0)
294
+ })
295
+
296
+ it('the stale-guard: a turn younger than minStaleMs produces no climb edit (labelled-branch symmetry)', () => {
297
+ const harness = new TurnHarness()
298
+ harness.activityMessageId = 55
299
+ const handled = runSilentTurnHeartbeatTick(
300
+ {
301
+ mirrorLineCount: 0,
302
+ activityMessageId: 55,
303
+ labeledToolCount: 0,
304
+ ageMs: FEED_HEARTBEAT_MIN_STALE_MS - 1,
305
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
306
+ },
307
+ harness.deps(() => null),
308
+ )
309
+ expect(handled).toBe(true) // still the 0-label branch's tick
310
+ expect(harness.calls.length).toBe(0)
311
+ })
312
+
313
+ it('the climb does not OPEN a card — no card yet delegates to the one shared OPEN path', () => {
314
+ const harness = new TurnHarness()
315
+ harness.now = 20_000
316
+ runSilentTurnHeartbeatTick(
317
+ {
318
+ mirrorLineCount: 0,
319
+ activityMessageId: null,
320
+ labeledToolCount: 0,
321
+ ageMs: 20_000,
322
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
323
+ },
324
+ harness.deps(() => 'open-render'),
325
+ )
326
+ // Delegated to openLivenessFeedIfDue; nothing else ran.
327
+ expect(harness.order).toEqual(['openLivenessFeedIfDue'])
328
+ expect(harness.sends.length).toBe(1) // the OPEN path opened it
329
+ expect(harness.edits.length).toBe(0)
330
+ })
331
+
332
+ it('silentTurnClimbRender refuses when no card is open (opening is reply-is-last gated elsewhere)', () => {
333
+ expect(
334
+ silentTurnClimbRender({ mirrorLineCount: 0, activityMessageId: null, labeledToolCount: 0, ageMs: 30_000 }),
335
+ ).toBeNull()
336
+ })
337
+ })
@@ -1296,6 +1296,145 @@ describe('startSubagentWatcher', () => {
1296
1296
  })
1297
1297
  })
1298
1298
 
1299
+ // ─── deterministic-turn-liveness.md Known-Gap 2 — worktree-isolated sub-agents ──
1300
+
1301
+ describe('worktree-isolated sub-agent visibility (turn-liveness Known Gap 2)', () => {
1302
+ /**
1303
+ * A sub-agent dispatched with `switchroom worktree claim` runs under a
1304
+ * different cwd than the parent agent, so Claude Code mints a
1305
+ * *different* `.claude/projects/<slug>/` tree for it. Pre-fix, the
1306
+ * #1116 foreign-slug filter above skips that slug forever — no
1307
+ * activity stamp, no `🛠 Worker` feed, for the whole worktree worker's
1308
+ * run. `extraWatchCwdsProvider` closes that gap: any cwd it returns is
1309
+ * treated as a first-class watched slug, same as `agentCwd`.
1310
+ */
1311
+
1312
+ let tmpRoot = ''
1313
+ const startedWatchers: Array<{ stop(): void }> = []
1314
+
1315
+ beforeEach(() => {
1316
+ tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-worktree-gap2-'))
1317
+ })
1318
+
1319
+ afterEach(() => {
1320
+ while (startedWatchers.length) {
1321
+ try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
1322
+ }
1323
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
1324
+ })
1325
+
1326
+ function startWatcherSync(opts: {
1327
+ agentDir: string
1328
+ agentCwd?: string
1329
+ extraWatchCwdsProvider?: () => string[]
1330
+ }): {
1331
+ logs: string[]
1332
+ poll: () => void
1333
+ watcher: ReturnType<typeof startSubagentWatcher>
1334
+ } {
1335
+ const logs: string[] = []
1336
+ const intervals: Array<{ fn: () => void; ref: number }> = []
1337
+ let nextRef = 1
1338
+ const watcher = startSubagentWatcher({
1339
+ agentDir: opts.agentDir,
1340
+ ...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
1341
+ ...(opts.extraWatchCwdsProvider !== undefined
1342
+ ? { extraWatchCwdsProvider: opts.extraWatchCwdsProvider }
1343
+ : {}),
1344
+ stallThresholdMs: 60_000,
1345
+ rescanMs: 500,
1346
+ now: () => Date.now(),
1347
+ setInterval: (fn) => {
1348
+ const ref = nextRef++
1349
+ intervals.push({ fn, ref })
1350
+ return { ref }
1351
+ },
1352
+ clearInterval: () => {},
1353
+ setTimeout: () => ({ ref: 0 }),
1354
+ clearTimeout: () => {},
1355
+ log: (msg) => logs.push(msg),
1356
+ })
1357
+ startedWatchers.push(watcher)
1358
+ return { logs, poll: () => intervals[0]?.fn(), watcher }
1359
+ }
1360
+
1361
+ it('watches a worktree-isolated cwd slug returned by extraWatchCwdsProvider', () => {
1362
+ const agentDir = join(tmpRoot, 'agent')
1363
+ const agentCwd = '/home/test/agent-own'
1364
+ const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
1365
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
1366
+ const worktreeSlug = join(
1367
+ agentDir,
1368
+ '.claude',
1369
+ 'projects',
1370
+ '-home-test--switchroom-worktrees-wt-abc123',
1371
+ 'sess-B',
1372
+ 'subagents',
1373
+ )
1374
+ mkdirSync(ownSubagents, { recursive: true })
1375
+ mkdirSync(worktreeSlug, { recursive: true })
1376
+ writeFileSync(
1377
+ join(ownSubagents, 'agent-ownworker.jsonl'),
1378
+ buildJSONL(subAgentUserMsg('legit work')),
1379
+ )
1380
+ writeFileSync(
1381
+ join(worktreeSlug, 'agent-worktreeworker.jsonl'),
1382
+ buildJSONL(subAgentUserMsg('worktree-isolated work')),
1383
+ )
1384
+
1385
+ // Pre-fix acceptance: without extraWatchCwdsProvider, the worktree
1386
+ // slug is a "foreign" slug per #1116 and is skipped — this is the
1387
+ // regression this test guards against.
1388
+ const preFix = startWatcherSync({ agentDir, agentCwd })
1389
+ preFix.poll()
1390
+ expect(preFix.watcher.getRegistry().has('worktreeworker')).toBe(false)
1391
+ preFix.watcher.stop()
1392
+
1393
+ const h = startWatcherSync({
1394
+ agentDir,
1395
+ agentCwd,
1396
+ extraWatchCwdsProvider: () => [worktreeCwd],
1397
+ })
1398
+ h.poll()
1399
+
1400
+ const reg = h.watcher.getRegistry()
1401
+ expect(reg.has('ownworker')).toBe(true)
1402
+ expect(reg.has('worktreeworker')).toBe(true)
1403
+ })
1404
+
1405
+ it('still skips a genuinely foreign project dir not returned by extraWatchCwdsProvider', () => {
1406
+ const agentDir = join(tmpRoot, 'agent')
1407
+ const agentCwd = '/home/test/agent-own'
1408
+ const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
1409
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
1410
+ const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-C', 'subagents')
1411
+ mkdirSync(ownSubagents, { recursive: true })
1412
+ mkdirSync(foreignSubagents, { recursive: true })
1413
+ writeFileSync(
1414
+ join(ownSubagents, 'agent-ownworker.jsonl'),
1415
+ buildJSONL(subAgentUserMsg('legit work')),
1416
+ )
1417
+ writeFileSync(
1418
+ join(foreignSubagents, 'agent-foreignworker.jsonl'),
1419
+ buildJSONL(subAgentUserMsg('a sibling agent, not a registered worktree')),
1420
+ )
1421
+
1422
+ const h = startWatcherSync({
1423
+ agentDir,
1424
+ agentCwd,
1425
+ // Registers a worktree cwd that is NOT the foreign dir above —
1426
+ // proves extraWatchCwdsProvider adds only what it explicitly
1427
+ // names, never a wildcard.
1428
+ extraWatchCwdsProvider: () => [worktreeCwd],
1429
+ })
1430
+ h.poll()
1431
+
1432
+ const reg = h.watcher.getRegistry()
1433
+ expect(reg.has('ownworker')).toBe(true)
1434
+ expect(reg.has('foreignworker')).toBe(false)
1435
+ })
1436
+ })
1437
+
1299
1438
  describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
1300
1439
  /**
1301
1440
  * Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
@@ -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