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,137 @@
1
+ /**
2
+ * Narrated mid-turn work still shows words on the card — CHANNEL twin of
3
+ * `jtbd-liveness-narration-dm.test.ts` (Phase 4a, `deterministic-turn-liveness.md`).
4
+ *
5
+ * Surface parity bar per the job spec. Like the DM twin (and unlike the
6
+ * previous version of this file), it also asserts the no-mid-turn-ping
7
+ * invariant inline — the RFC and job spec claim the ping-free assertion is
8
+ * baked into EVERY liveness scenario, DM and channel alike. Self-skips green
9
+ * when no test supergroup is wired.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { spinUp } from "../harness.js";
14
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
15
+ import type { ObservedMessage } from "../driver.js";
16
+
17
+ const AGENT = "test-harness";
18
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
19
+ const OVERALL_BUDGET_MS = 150_000;
20
+
21
+ const NARRATED_WORK_PROMPT =
22
+ "Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
23
+ 'one-sentence narration of what you are about to do (e.g. "Checking the ' +
24
+ 'hostname now.") — this is not a reply, just your normal narration — then ' +
25
+ "run the step via Bash (`sleep 3` is fine as the step body). After all " +
26
+ "four steps, send a one-line final summary reply.";
27
+
28
+ /** Body (narration/tool) lines of a card, minus header + climb placeholder.
29
+ * Mirrors the DM twin's `narratedBodyLines`. */
30
+ function narratedBodyLines(text: string): string[] {
31
+ return text
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l.length > 0)
35
+ .map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
36
+ .filter(
37
+ (l) =>
38
+ l.length > 0 &&
39
+ !/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
40
+ !/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
41
+ !/^Working(?:\s*[·…]|$)/i.test(l) &&
42
+ !/^\+?\d+\s+earlier/i.test(l),
43
+ );
44
+ }
45
+
46
+ describe("uat: deterministic turn liveness — narration lands mid-turn (channel parity)", () => {
47
+ it(
48
+ "the model's own words appear on the activity card mid-turn in a supergroup, with no mid-turn ping",
49
+ async () => {
50
+ if (!Number.isFinite(SUPERGROUP_ID)) {
51
+ console.warn("[liveness-narration-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
52
+ return;
53
+ }
54
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
55
+ try {
56
+ await sc.driver.primeDialogs();
57
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
58
+ console.warn(`[liveness-narration-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
59
+ return;
60
+ }
61
+
62
+ const iter = sc.driver
63
+ .observeMessages(SUPERGROUP_ID)
64
+ [Symbol.asyncIterator]();
65
+
66
+ await sc.driver.sendText(SUPERGROUP_ID, NARRATED_WORK_PROMPT);
67
+ const sentAt = Date.now();
68
+
69
+ const narrationSamples: string[] = [];
70
+ let answer: ObservedMessage | null = null;
71
+ let otherLoudMessage: ObservedMessage | null = null;
72
+
73
+ const deadline = Date.now() + 100_000;
74
+ while (Date.now() < deadline) {
75
+ if (answer) break;
76
+ const remaining = deadline - Date.now();
77
+ const next = await Promise.race([
78
+ iter.next(),
79
+ new Promise<{ done: true; value: undefined }>((r) =>
80
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
81
+ ),
82
+ ]);
83
+ if (next.done || next.value == null) break;
84
+ const m = next.value as ObservedMessage;
85
+ if (m.senderUserId === sc.driverUserId) continue;
86
+
87
+ if (isActivityFeedMessage(m)) {
88
+ const narrated = narratedBodyLines(m.text);
89
+ if (narrated.length > 0) {
90
+ narrationSamples.push(narrated.join(" | "));
91
+ console.log(
92
+ `[liveness-narration-channel] narrated card content at +${Date.now() - sentAt}ms: ` +
93
+ JSON.stringify(narrated.join(" | ").slice(0, 140)),
94
+ );
95
+ }
96
+ if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
97
+ continue;
98
+ }
99
+ if (m.edited) continue;
100
+ if (isFrameworkFallbackText(m.text)) {
101
+ otherLoudMessage = otherLoudMessage ?? m;
102
+ continue;
103
+ }
104
+ if (!answer && m.text.trim().length > 0) {
105
+ answer = m;
106
+ continue;
107
+ }
108
+ if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
109
+ }
110
+ await iter.return?.();
111
+
112
+ // No-mid-turn-ping invariant, inline in the channel twin too.
113
+ expect(
114
+ otherLoudMessage,
115
+ `a mid-turn message eroded the ping-free/edit-only guarantee in the supergroup: ` +
116
+ JSON.stringify(otherLoudMessage?.text?.slice(0, 120)),
117
+ ).toBeNull();
118
+
119
+ if (narrationSamples.length === 0) {
120
+ console.warn(
121
+ "[liveness-narration-channel] INCONCLUSIVE — no narrated content observed on the " +
122
+ "card (model may have batched steps or the turn was too fast).",
123
+ );
124
+ }
125
+
126
+ expect(
127
+ answer,
128
+ "FAIL — no answer (distinct from the activity card) arrived within budget; the turn may be wedged.",
129
+ ).not.toBeNull();
130
+ expect(answer?.chatId).toBe(SUPERGROUP_ID);
131
+ } finally {
132
+ await sc.tearDown();
133
+ }
134
+ },
135
+ OVERALL_BUDGET_MS,
136
+ );
137
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Narrated mid-turn work still shows words on the card (Phase 4a,
3
+ * `deterministic-turn-liveness.md`) — DM.
4
+ *
5
+ * The Phase-1 climb fills the GAPS the model leaves; it must never regress
6
+ * the pre-existing narration path (session-tail → gateway `case 'text'` →
7
+ * `stagePendingNarrative` → `showNarrativeStep` → `appendActivityLabel`).
8
+ * This scenario proves the model's own narrated steps still land on the
9
+ * card mid-turn, chronologically interleaved with the climb, when the model
10
+ * DOES talk (the complement of `jtbd-liveness-climb-dm`, which forces the
11
+ * model silent).
12
+ *
13
+ * IMPORTANT: switchroom has no streaming REPLY path. The words asserted here
14
+ * land on the pre-existing activity/liveness card (a card-edit surface), not
15
+ * a streamed reply — the final reply is still sent whole.
16
+ */
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import { spinUp } from "../harness.js";
20
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
21
+ import type { ObservedMessage } from "../driver.js";
22
+
23
+ /**
24
+ * Extract the narration/tool body lines of an activity card, dropping the
25
+ * two-line header (`🤖 Agent` / `<elapsed> · <N> tools`) and the bare
26
+ * `Working…` placeholder the 0-label climb paints. Whatever remains is
27
+ * model-authored narration or tool labels — the content this scenario proves
28
+ * still lands on the card mid-turn.
29
+ */
30
+ function narratedBodyLines(text: string): string[] {
31
+ return text
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l.length > 0)
35
+ // strip glyphs so a `→ Working…` placeholder compares as `Working…`
36
+ .map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
37
+ .filter(
38
+ (l) =>
39
+ l.length > 0 &&
40
+ // header line 1 (emoji + label, optional description)
41
+ !/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
42
+ // header line 2 (elapsed · tools, running or done)
43
+ !/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
44
+ // the bare climb placeholder is NOT narration
45
+ !/^Working(?:\s*[·…]|$)/i.test(l) &&
46
+ // rolling-overflow header is not narration
47
+ !/^\+?\d+\s+earlier/i.test(l),
48
+ );
49
+ }
50
+
51
+ const OVERALL_BUDGET_MS = 130_000;
52
+
53
+ const NARRATED_WORK_PROMPT =
54
+ "Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
55
+ 'one-sentence narration of what you are about to do (e.g. "Checking the ' +
56
+ 'hostname now.") — this is not a reply, just your normal narration — then ' +
57
+ "run the step via Bash (`sleep 3` is fine as the step body). After all " +
58
+ "four steps, send a one-line final summary reply.";
59
+
60
+ describe("uat: deterministic turn liveness — narration lands mid-turn (DM)", () => {
61
+ it(
62
+ "the model's own words appear on the activity card mid-turn (narration not regressed)",
63
+ async () => {
64
+ const sc = await spinUp({ agent: "test-harness" });
65
+ try {
66
+ const iter = sc.driver
67
+ .observeMessages(sc.botUserId)
68
+ [Symbol.asyncIterator]();
69
+
70
+ await sc.sendDM(NARRATED_WORK_PROMPT);
71
+ const sentAt = Date.now();
72
+ console.log("[liveness-narration-dm] prompt sent; watching for narrated card content…");
73
+
74
+ const narrationSamples: string[] = [];
75
+ let answer: ObservedMessage | null = null;
76
+ let otherLoudMessage: ObservedMessage | null = null;
77
+
78
+ const deadline = Date.now() + 90_000;
79
+ while (Date.now() < deadline) {
80
+ if (answer) break;
81
+ const remaining = deadline - Date.now();
82
+ const next = await Promise.race([
83
+ iter.next(),
84
+ new Promise<{ done: true; value: undefined }>((r) =>
85
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
86
+ ),
87
+ ]);
88
+ if (next.done || next.value == null) break;
89
+ const m = next.value as ObservedMessage;
90
+ if (m.senderUserId === sc.driverUserId) continue;
91
+
92
+ if (isActivityFeedMessage(m)) {
93
+ // The card carries the two-line header + (climb placeholder |
94
+ // narrated body). Any body line that ISN'T the header or the bare
95
+ // "Working…" placeholder is model narration landing mid-turn.
96
+ const narrated = narratedBodyLines(m.text);
97
+ if (narrated.length > 0) {
98
+ narrationSamples.push(narrated.join(" | "));
99
+ console.log(
100
+ `[liveness-narration-dm] narrated card content at +${Date.now() - sentAt}ms: ` +
101
+ JSON.stringify(narrated.join(" | ").slice(0, 140)),
102
+ );
103
+ }
104
+ if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
105
+ continue;
106
+ }
107
+ if (m.edited) continue;
108
+ // A framework fallback TEXT send is a mid-turn erosion, not the
109
+ // answer — reject it from the answer lane so it trips the assertion.
110
+ if (isFrameworkFallbackText(m.text)) {
111
+ otherLoudMessage = otherLoudMessage ?? m;
112
+ continue;
113
+ }
114
+ if (!answer && m.text.trim().length > 0) {
115
+ answer = m;
116
+ continue;
117
+ }
118
+ if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
119
+ }
120
+ await iter.return?.();
121
+
122
+ expect(
123
+ otherLoudMessage,
124
+ `a mid-turn message pinged the user's device: ${JSON.stringify(otherLoudMessage?.text?.slice(0, 120))}`,
125
+ ).toBeNull();
126
+
127
+ if (narrationSamples.length === 0) {
128
+ console.warn(
129
+ "[liveness-narration-dm] INCONCLUSIVE — no narrated content observed on the " +
130
+ "card (model may have batched steps or the turn was too fast). Not " +
131
+ "necessarily a regression — but worth a human look if this recurs.",
132
+ );
133
+ }
134
+ // (No tautological `expect(narrationSamples.length).toBeGreaterThan(0)`
135
+ // in the else-branch — it was asserting a condition the branch already
136
+ // guarantees. The meaningful signals are the ping-free assertion above
137
+ // and the answer-arrived assertion below; narration presence is
138
+ // logged, not force-asserted, since a live model may legitimately
139
+ // batch its steps.)
140
+
141
+ expect(answer, "FAIL — no answer arrived within budget; the turn may be wedged.").not.toBeNull();
142
+ } finally {
143
+ await sc.tearDown();
144
+ }
145
+ },
146
+ OVERALL_BUDGET_MS,
147
+ );
148
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Sparse chat-legible memory in a SUPERGROUP (#2849, hindsight Phase 4) — UAT.
3
+ *
4
+ * Channel twin of `jtbd-memory-legibility-dm`. A directive stored while the
5
+ * conversation is happening in a supergroup must surface its `📌 remembered:`
6
+ * line IN the supergroup — never the operator DM (the known status-routing
7
+ * bug class). Proves the memory-legibility status surface has DM/channel
8
+ * parity: it routes on the ORIGINATING topic (`turn.sessionChatId` /
9
+ * `sessionThreadId`), not the owner DM.
10
+ *
11
+ * Self-skips green when no test supergroup is wired (SWITCHROOM_UAT_CHAT_ID
12
+ * unset or not resolvable). Uses the General topic (mtcute here has no
13
+ * forum-topic create API).
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+ import { spinUp } from "../harness.js";
18
+ import { expectMessage } from "../assertions.js";
19
+
20
+ const AGENT = "test-harness";
21
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
22
+
23
+ const REMEMBER_PROMPT =
24
+ `Persist this as a standing directive using the ` +
25
+ `mcp__hindsight__create_directive tool, with the directive content set ` +
26
+ `to exactly: "Always greet me as Captain at the start of a reply". ` +
27
+ `Name the directive "greeting-captain". After creating it, send a brief ` +
28
+ `one-line confirmation.`;
29
+
30
+ const REMEMBERED_RE = /📌\s*remembered/i;
31
+
32
+ describe("uat: sparse chat-legible memory in a supergroup (#2849 channel parity)", () => {
33
+ it(
34
+ "surfaces the 📌 remembered line IN the supergroup, not the DM",
35
+ async () => {
36
+ if (!Number.isFinite(SUPERGROUP_ID)) {
37
+ console.warn("[memory-legibility channel UAT] SWITCHROOM_UAT_CHAT_ID unset — skipping");
38
+ return;
39
+ }
40
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
41
+ try {
42
+ await sc.driver.primeDialogs();
43
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
44
+ console.warn(`[memory-legibility channel UAT] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
45
+ return;
46
+ }
47
+ await sc.driver.sendText(SUPERGROUP_ID, REMEMBER_PROMPT);
48
+
49
+ const line = await expectMessage(sc.driver, SUPERGROUP_ID, REMEMBERED_RE, {
50
+ timeout: 90_000,
51
+ senderFilter: { notUserId: sc.driverUserId },
52
+ });
53
+ console.log(
54
+ `[memory-legibility channel UAT] remembered line (id=${line.messageId}, chat=${line.chatId}): ${JSON.stringify(line.text)}`,
55
+ );
56
+ expect(line.chatId).toBe(SUPERGROUP_ID); // parity proof: in the channel, not the DM
57
+ expect(line.fromBot).toBe(true);
58
+ expect(line.messageId).toBeGreaterThan(0);
59
+ expect(line.text).toMatch(/Captain/i);
60
+ } finally {
61
+ await sc.tearDown();
62
+ }
63
+ },
64
+ 180_000,
65
+ );
66
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Sparse chat-legible memory (#2849, hindsight Phase 4) — DM UAT.
3
+ *
4
+ * Serves the `remember-across-sessions` job. When the interactive session
5
+ * stores a new standing directive, ONE terse line must surface in the
6
+ * originating chat: `📌 remembered: "<directive>"`. It's a REAL message
7
+ * (sendMessage, not a draft/reaction) so mtcute can observe it. The line
8
+ * is sparse + material-only — ordinary recall never surfaces one — and is
9
+ * ON by default (kill-switch SWITCHROOM_MEMORY_LEGIBILITY=0).
10
+ *
11
+ * This scenario asks the agent to persist an explicit standing rule via
12
+ * the create_directive tool, then asserts the 📌 remembered line appears
13
+ * from the bot in the DM.
14
+ *
15
+ * Note: the model must actually call `mcp__hindsight__create_directive`.
16
+ * The prompt names the tool + the rule verbatim to make the tool call
17
+ * deterministic. Requires the test-harness agent running an image that
18
+ * includes #2849.
19
+ */
20
+
21
+ import { describe, expect, it } from "vitest";
22
+ import { spinUp } from "../harness.js";
23
+
24
+ const REMEMBER_PROMPT =
25
+ `Persist this as a standing directive using the ` +
26
+ `mcp__hindsight__create_directive tool, with the directive content set ` +
27
+ `to exactly: "Always greet me as Captain at the start of a reply". ` +
28
+ `Name the directive "greeting-captain". After creating it, send a brief ` +
29
+ `one-line confirmation.`;
30
+
31
+ // The legibility line: `📌 remembered: "<directive body, truncated>"`.
32
+ const REMEMBERED_RE = /📌\s*remembered/i;
33
+
34
+ describe("uat: sparse chat-legible memory — remembered line (#2849)", () => {
35
+ it(
36
+ "surfaces a 📌 remembered line in the DM when a directive is created",
37
+ async () => {
38
+ const sc = await spinUp({ agent: "test-harness" });
39
+ try {
40
+ await sc.sendDM(REMEMBER_PROMPT);
41
+
42
+ // The 📌 remembered line — a real bot message in this DM. May arrive
43
+ // alongside/near the agent's confirmation reply; generous budget for
44
+ // the tool round-trip + transcript flush.
45
+ const line = await sc.expectMessage(REMEMBERED_RE, {
46
+ from: "bot",
47
+ timeout: 90_000,
48
+ });
49
+ console.log(
50
+ `[memory-legibility UAT] remembered line (id=${line.messageId}): ${JSON.stringify(line.text)}`,
51
+ );
52
+ expect(line.messageId).toBeGreaterThan(0);
53
+ // The directive body should ride along in the line.
54
+ expect(line.text).toMatch(/Captain/i);
55
+ } finally {
56
+ await sc.tearDown();
57
+ }
58
+ },
59
+ 180_000,
60
+ );
61
+ });
@@ -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
+ );
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Silent-end recovery — CHANNEL twin of `silent-end-recovery-dm.test.ts`
3
+ * (Phase 4a, `deterministic-turn-liveness.md`: the dark-turn guard must
4
+ * deliver exactly one fallback message on the real surface, in a DM AND a
5
+ * forum topic alike).
6
+ *
7
+ * Phase 2 (already shipped) hardens `recordUndeliveredTurnEnd` /
8
+ * `SILENT_END_FALLBACK_TEXT` as the ONE deterministic guard for a turn that
9
+ * ends having delivered no reply. This scenario proves it fires — at most
10
+ * once — for the same tool-heavy prompt shape, in a supergroup, AND that no
11
+ * mid-turn device ping is emitted before the terminal reply/fallback (the
12
+ * ping-free invariant the RFC/job spec claim is inline in every scenario).
13
+ * The terminal dark-turn fallback itself is a sanctioned loud unwedge send, so
14
+ * only pings STRICTLY BEFORE the terminal message count as erosion.
15
+ *
16
+ * Self-skips green when no test supergroup is wired.
17
+ */
18
+
19
+ import { describe, it, expect } from "vitest";
20
+ import { spinUp } from "../harness.js";
21
+ import { expectMessage, isActivityFeedMessage } from "../assertions.js";
22
+ import type { ObservedMessage } from "../driver.js";
23
+
24
+ const AGENT = "test-harness";
25
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
26
+
27
+ const TOOL_HEAVY_PROMPT =
28
+ "Pick a directory under /tmp that doesn't exist yet. Create it. " +
29
+ "List its contents (should be empty). Write a small file in it. " +
30
+ "List again. Then report what you did in a one-line reply.";
31
+
32
+ describe("uat: silent-end recovery (channel parity)", () => {
33
+ it(
34
+ "user asks in a supergroup → agent replies exactly once via the fallback ladder if needed, no mid-turn ping",
35
+ async () => {
36
+ if (!Number.isFinite(SUPERGROUP_ID)) {
37
+ console.warn("[silent-end-recovery-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
38
+ return;
39
+ }
40
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
41
+ try {
42
+ await sc.driver.primeDialogs();
43
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
44
+ console.warn(`[silent-end-recovery-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
45
+ return;
46
+ }
47
+
48
+ const iter = sc.driver
49
+ .observeMessages(SUPERGROUP_ID)
50
+ [Symbol.asyncIterator]();
51
+
52
+ await sc.driver.sendText(SUPERGROUP_ID, TOOL_HEAVY_PROMPT);
53
+ const sentAt = Date.now();
54
+
55
+ // Same budget rationale as the DM twin: covers normal latency, one
56
+ // Stop-hook re-prompt cycle, and the worst-case 300s framework
57
+ // fallback. We observe the whole window so we can assert the ping-free
58
+ // invariant BEFORE the terminal reply, not just wait for the reply.
59
+ let reply: ObservedMessage | null = null;
60
+ let midTurnPing: ObservedMessage | null = null;
61
+
62
+ const deadline = Date.now() + 320_000;
63
+ while (Date.now() < deadline) {
64
+ if (reply) break;
65
+ const remaining = deadline - Date.now();
66
+ const next = await Promise.race([
67
+ iter.next(),
68
+ new Promise<{ done: true; value: undefined }>((r) =>
69
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
70
+ ),
71
+ ]);
72
+ if (next.done || next.value == null) break;
73
+ const m = next.value as ObservedMessage;
74
+ if (m.senderUserId === sc.driverUserId) continue;
75
+
76
+ // Card edits are the sanctioned mid-turn liveness surface (silent by
77
+ // construction) — skip them from the ping accounting.
78
+ if (isActivityFeedMessage(m)) {
79
+ if (m.edited && m.silent === false) midTurnPing = midTurnPing ?? m;
80
+ continue;
81
+ }
82
+ if (m.edited) continue;
83
+ // First substantive non-card bot message is the terminal reply
84
+ // (model answer OR the dark-turn fallback — both terminal, both may
85
+ // legitimately ping).
86
+ if (m.text.trim().length > 0) {
87
+ reply = m;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ expect(
93
+ midTurnPing,
94
+ `a mid-turn message pinged the device BEFORE the terminal reply in the supergroup: ` +
95
+ JSON.stringify(midTurnPing?.text?.slice(0, 120)),
96
+ ).toBeNull();
97
+
98
+ expect(reply, "FAIL — no reply arrived within budget; the silent-end guard may have regressed.").not.toBeNull();
99
+ expect(reply!.text.length).toBeGreaterThan(0);
100
+ expect(reply!.chatId).toBe(SUPERGROUP_ID);
101
+ console.log(`[silent-end-recovery-channel] reply at +${Date.now() - sentAt}ms.`);
102
+
103
+ if (/no update from agent|didn't send a reply/i.test(reply!.text)) {
104
+ console.warn(
105
+ "[silent-end-recovery-channel] reply was the framework/dark-turn fallback — " +
106
+ `model never replied on its own. Reply text: ${JSON.stringify(reply!.text.slice(0, 200))}`,
107
+ );
108
+
109
+ // Exactly-once proof: no SECOND fallback-shaped message should
110
+ // follow within a short window (the `exhausted` latch).
111
+ let secondFallback: ObservedMessage | null = null;
112
+ try {
113
+ secondFallback = await expectMessage(
114
+ sc.driver,
115
+ SUPERGROUP_ID,
116
+ /no update from agent|didn't send a reply/i,
117
+ { timeout: 15_000, senderFilter: { notUserId: sc.driverUserId } },
118
+ );
119
+ } catch {
120
+ // Timeout is the expected/good outcome — no second fallback fired.
121
+ }
122
+ expect(
123
+ secondFallback,
124
+ "the dark-turn/framework fallback fired MORE than once for the same turn " +
125
+ "(the `exhausted` latch should give fire-once)",
126
+ ).toBeNull();
127
+ }
128
+
129
+ await iter.return?.();
130
+ } finally {
131
+ await sc.tearDown();
132
+ }
133
+ },
134
+ 360_000,
135
+ );
136
+ });