switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -84,10 +84,10 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
84
84
  "**→ Reading gateway.ts**",
85
85
  );
86
86
  expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
87
- "_✓ Reading gateway.ts_\n**→ Searching memory**",
87
+ "~~_✓ Reading gateway.ts_~~ \n**→ Searching memory**",
88
88
  );
89
89
  expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
90
- "_✓ Reading gateway.ts_\n_✓ Searching memory_\n**→ List workspace**",
90
+ "~~_✓ Reading gateway.ts_~~ \n~~_✓ Searching memory_~~ \n**→ List workspace**",
91
91
  );
92
92
  });
93
93
 
@@ -110,14 +110,14 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
110
110
  const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
111
111
  const out = renderActivityFeed(lines)!;
112
112
  const hidden = total - STATUS_ROLLING_LINES;
113
- expect(out.startsWith(`_✓ +${hidden} earlier…_\n`)).toBe(true);
113
+ expect(out.startsWith(`_✓ +${hidden} earlier…_ \n`)).toBe(true);
114
114
  // Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
115
115
  const firstVisible = total - STATUS_ROLLING_LINES + 1;
116
- expect(out).toContain(`_✓ Action ${firstVisible}_`);
116
+ expect(out).toContain(`~~_✓ Action ${firstVisible}_~~`);
117
117
  expect(out).not.toContain(`Action ${firstVisible - 1}<`);
118
118
  // The newest action is the in-progress step (bold →); the rest are done (✓).
119
119
  expect(out).toContain(`**→ Action ${total}**`);
120
- expect(out).toContain(`_✓ Action ${total - 1}_`);
120
+ expect(out).toContain(`~~_✓ Action ${total - 1}_~~`);
121
121
  expect(out).not.toContain(`**→ Action ${total - 1}**`);
122
122
  });
123
123
 
@@ -145,26 +145,46 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
145
145
  const lines = ["Reading a.ts", "Searching memory", "Running a command"];
146
146
  const out = renderActivityFeed(lines, true)!;
147
147
  expect(out).toBe(
148
- "_✓ Reading a.ts_\n_✓ Searching memory_\n_✓ Running a command_",
148
+ "~~_✓ Reading a.ts_~~ \n~~_✓ Searching memory_~~ \n~~_✓ Running a command_~~",
149
149
  );
150
150
  expect(out).not.toContain("→"); // no in-progress arrow anywhere
151
151
  });
152
152
 
153
153
  it("final=true on a single line is also done (✓)", () => {
154
- expect(renderActivityFeed(["Reading a.ts"], true)).toBe("_✓ Reading a.ts_");
154
+ expect(renderActivityFeed(["Reading a.ts"], true)).toBe("~~_✓ Reading a.ts_~~");
155
155
  });
156
156
 
157
157
  it("final defaults false (live render keeps the → in-progress newest line)", () => {
158
158
  expect(renderActivityFeed(["Reading a.ts"])).toBe("**→ Reading a.ts**");
159
159
  });
160
160
 
161
+ // Strikethrough contract (operator request): done (✓) step lines render with
162
+ // GFM strikethrough wrapping the italic so completed items visually recede,
163
+ // while the active (→ bold) line is never struck.
164
+ it("done step lines are struck (~~) AND italic; the active → line is not struck", () => {
165
+ const out = renderActivityFeed(["Reading a.ts", "Searching memory"])!;
166
+ // earlier step is done → struck italic
167
+ expect(out).toContain("~~_✓ Reading a.ts_~~");
168
+ // newest step is active → bold, NOT struck
169
+ expect(out).toContain("**→ Searching memory**");
170
+ expect(out).not.toContain("~~_✓ Searching memory_~~");
171
+ expect(out).not.toContain("~~**→");
172
+ // the meta/footer count lines stay UNSTRUCK (kept legible as meta)
173
+ const overflow = Array.from({ length: STATUS_ROLLING_LINES + 2 }, (_, i) => `Action ${i + 1}`);
174
+ const finalOut = renderActivityFeed(overflow, true, "", overflow.length)!;
175
+ expect(finalOut).toContain(`_✓ +${overflow.length - STATUS_ROLLING_LINES} earlier…_`);
176
+ expect(finalOut).toContain(`_✓ ${overflow.length} steps_`);
177
+ expect(finalOut).not.toContain(`~~_✓ +`);
178
+ expect(finalOut).not.toContain(`~~_✓ ${overflow.length} steps_~~`);
179
+ });
180
+
161
181
  // liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
162
182
  // long single step visibly advances ("→ Pulling Meta data · 18s") even though
163
183
  // the feed is pull-only and no new tool label arrived.
164
184
  describe("liveSuffix (heartbeat)", () => {
165
185
  it("appends the suffix to the newest in-progress line only", () => {
166
186
  expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
167
- "_✓ Reading a.ts_\n**→ Running a command · 18s**",
187
+ "~~_✓ Reading a.ts_~~ \n**→ Running a command · 18s**",
168
188
  );
169
189
  });
170
190
  it("single live line gets the suffix", () => {
@@ -176,7 +196,7 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
176
196
  const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
177
197
  expect(out).not.toContain("·");
178
198
  expect(out).not.toContain("→");
179
- expect(out).toBe("_✓ Reading a.ts_\n_✓ Running a command_");
199
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n~~_✓ Running a command_~~");
180
200
  });
181
201
  it("default empty suffix is byte-identical to no suffix", () => {
182
202
  expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
@@ -191,7 +211,7 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
191
211
  const lines: string[] = [];
192
212
  expect(appendActivityLabel(lines, "Searching memory")).toBe("**→ Searching memory**");
193
213
  expect(appendActivityLabel(lines, "List workspace")).toBe(
194
- "_✓ Searching memory_\n**→ List workspace**",
214
+ "~~_✓ Searching memory_~~ \n**→ List workspace**",
195
215
  );
196
216
  // consecutive dup collapses
197
217
  appendActivityLabel(lines, "List workspace");
@@ -221,10 +241,15 @@ describe("clipNarrative — narrative-step clip (JSONL-text-narrative primitive)
221
241
  const narrativeLines: string[] = [];
222
242
  const labelLines: string[] = [];
223
243
  const text = "Important find: no main branch yet — branching off origin.";
244
+ // F1: the feed renders through renderStatusCard → stripMarkdown, which now
245
+ // normalizes em/en-dashes on this card surface. A narrative and a label go
246
+ // through the exact same path, so the render is still byte-identical — the
247
+ // dash is scrubbed to a full stop (new sentence, recapitalized) in BOTH.
248
+ const scrubbed = "Important find: no main branch yet. Branching off origin.";
224
249
  const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
225
250
  const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
226
251
  expect(narrativeRender).toBe(labelRender);
227
- expect(narrativeRender).toBe(`**→ ${text}**`);
252
+ expect(narrativeRender).toBe(`**→ ${scrubbed}**`);
228
253
  });
229
254
 
230
255
  it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
@@ -238,7 +263,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
238
263
  const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
239
264
  const out = renderActivityFeed(lines, true, "", 5)!;
240
265
  // All lines are done (✓) and the footer is appended.
241
- expect(out).toContain("_✓ Running a command_");
266
+ expect(out).toContain("~~_✓ Running a command_~~");
242
267
  expect(out).toContain("_✓ 5 steps_");
243
268
  expect(out.endsWith("_✓ 5 steps_")).toBe(true);
244
269
  });
@@ -247,7 +272,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
247
272
  const lines = ["Reading CLAUDE.md"];
248
273
  const out = renderActivityFeed(lines, true, "", 0)!;
249
274
  expect(out).not.toContain("steps");
250
- expect(out).toBe("_✓ Reading CLAUDE.md_");
275
+ expect(out).toBe("~~_✓ Reading CLAUDE.md_~~");
251
276
  });
252
277
 
253
278
  it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
@@ -307,11 +332,11 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
307
332
  const child = ["Reading schema.ts", "Looking for foreign keys"];
308
333
  const out = renderActivityFeedWithNested(parent, child)!;
309
334
  // Parent is blocked at the Task tool → none of its lines is the live step.
310
- expect(out).toContain("_✓ Searching memory_");
311
- expect(out).toContain("_✓ Delegating: review the migration_");
335
+ expect(out).toContain("~~_✓ Searching memory_~~");
336
+ expect(out).toContain("~~_✓ Delegating: review the migration_~~");
312
337
  expect(out).not.toContain("**→ Delegating");
313
338
  // The live → step is the newest nested child line; earlier child = italic.
314
- expect(out).toContain(" ↳ _Reading schema.ts_");
339
+ expect(out).toContain(" ↳ ~~_Reading schema.ts_~~");
315
340
  expect(out).toContain(" ↳ **→ Looking for foreign keys**");
316
341
  });
317
342
 
@@ -342,13 +367,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
342
367
  ["Reading schema.ts", "Looking for foreign keys"],
343
368
  true,
344
369
  )!;
345
- expect(out).toContain(" ↳ _Looking for foreign keys_"); // newest now italic done
370
+ expect(out).toContain(" ↳ ~~_Looking for foreign keys_~~"); // newest now struck italic done
346
371
  expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
347
372
  });
348
373
 
349
374
  it("final=true with no children delegates to the finalized flat render", () => {
350
375
  expect(renderActivityFeedWithNested(["Reading a.ts"], [], true)).toBe(
351
- "_✓ Reading a.ts_",
376
+ "~~_✓ Reading a.ts_~~",
352
377
  );
353
378
  });
354
379
 
@@ -381,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
381
406
  it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
382
407
  const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
383
408
  expect(out).toContain("_✓ 3 steps_");
384
- expect(out).toBe("_✓ Reading a.ts_\n_✓ 3 steps_");
409
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n_✓ 3 steps_");
385
410
  });
386
411
 
387
412
  // Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
@@ -398,7 +423,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
398
423
 
399
424
  it("finalizes the placeholder to a done record (no frozen → line)", () => {
400
425
  expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
401
- "_✓ Working…_",
426
+ "~~_✓ Working…_~~",
402
427
  );
403
428
  });
404
429
 
@@ -612,8 +637,8 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
612
637
  expect(out).not.toBeNull();
613
638
  expect(out.length).toBeLessThanOrEqual(4000);
614
639
  expect(isValidMarkdown(out)).toBe(true);
615
- expect(out.startsWith("_✓ ")).toBe(true);
616
- expect(out.endsWith("_")).toBe(true);
640
+ expect(out.startsWith("~~_✓ ")).toBe(true);
641
+ expect(out.endsWith("_~~")).toBe(true);
617
642
  });
618
643
 
619
644
  it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid markdown", () => {
@@ -770,7 +795,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
770
795
  expect(out).toContain("🤖 **Agent**");
771
796
  expect(out).toContain("_15s · 7 tools_");
772
797
  // Step feed follows the header.
773
- expect(out).toContain("_✓ Reading CLAUDE.md_");
798
+ expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
774
799
  expect(out).toContain("**→ Searching memory**");
775
800
  });
776
801
 
@@ -784,7 +809,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
784
809
  const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
785
810
  expect(out).toContain("🤖 **Agent**");
786
811
  expect(out).toContain("_done · 3 tools · 1m05s_");
787
- expect(out).toContain("_✓ Reading CLAUDE.md_");
812
+ expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
788
813
  // No in-progress arrow (final=true).
789
814
  expect(out).not.toContain("→");
790
815
  });
@@ -824,7 +849,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
824
849
  expect(out).toContain("🤖 **Agent**");
825
850
  expect(out).toContain("_30s · 5 tools_");
826
851
  // Parent step is done-styled (child is the live step).
827
- expect(out).toContain("_✓ Delegating: review_");
852
+ expect(out).toContain("~~_✓ Delegating: review_~~");
828
853
  // Child step is the in-progress step.
829
854
  expect(out).toContain(" ↳ **→ Reading schema.ts**");
830
855
  });
@@ -847,3 +872,41 @@ describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
847
872
  expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
848
873
  });
849
874
  });
875
+
876
+ describe("status-card body stacks like a reply (#2669 rich-message renderer)", () => {
877
+ // The single most-seen surface: the operator's progress card. Its step lines
878
+ // are styled prose (`~~_✓ …_~~`, `**→ …**`), NOT GFM list items, so a lone `\n`
879
+ // between them is a SOFT break the renderer collapses — bullets ran together
880
+ // into one line. The card now joins via stackCardLines (hard breaks), so the
881
+ // bullets stack exactly as the reply path renders them.
882
+
883
+ it("multiple step bullets stack on their own lines (no soft-break collapse)", () => {
884
+ const out = renderActivityFeed(["Reading a.ts", "Searching memory", "Running tests"], true)!;
885
+ const lines = out.split(" \n");
886
+ expect(lines).toEqual(["~~_✓ Reading a.ts_~~", "~~_✓ Searching memory_~~", "~~_✓ Running tests_~~"]);
887
+ // Not a single lone `\n` survives to collapse two bullets.
888
+ expect(out).not.toMatch(/(?<! {2})\n/);
889
+ });
890
+
891
+ it("the collapsed-inline-bullet case is fixed on a card body specifically", () => {
892
+ // Three labels that, joined by a lone `\n`, would render as a single run-on
893
+ // visual line in the rich renderer. After stacking, each renders distinctly.
894
+ const out = renderActivityFeed(["Editing foo.ts", "Editing bar.ts", "Writing baz.ts"])!;
895
+ // Every adjacent pair is separated by a hard break.
896
+ expect(out.match(/ {2}\n/g)?.length).toBe(2);
897
+ // The newest is the in-progress bold step; earlier are done — all visible.
898
+ expect(out).toContain("~~_✓ Editing foo.ts_~~");
899
+ expect(out).toContain("~~_✓ Editing bar.ts_~~");
900
+ expect(out).toContain("**→ Writing baz.ts**");
901
+ });
902
+
903
+ it("stacking does not corrupt the card's own italic/bold wrapper markup", () => {
904
+ // The status card wraps each step in `_…_` (done) or `**→ …**` (live). The
905
+ // stacking pass only changes the SEPARATOR between lines — the per-line
906
+ // wrapper markup must be byte-identical. (Step TEXT is stripped to plain
907
+ // prose by escapeStepLine; the inline-markup-survives case is covered by
908
+ // the stackCardLines unit test, which operates on pre-rendered lines.)
909
+ const out = renderActivityFeed(["Reading a.ts", "Running tests"], false)!;
910
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n**→ Running tests**");
911
+ });
912
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for tts-normalize.ts — deterministic L1 TTS normalization
3
+ * (issue #2760, Phase 1). Bun test (this dir uses bun test, not vitest).
4
+ */
5
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
6
+ import { normalizeForTts, ttsNormalizeEnabled } from '../tts-normalize.js'
7
+
8
+ const KILL = 'SWITCHROOM_DISABLE_TTS_NORMALIZE'
9
+
10
+ let savedKill: string | undefined
11
+
12
+ beforeEach(() => {
13
+ savedKill = process.env[KILL]
14
+ delete process.env[KILL]
15
+ })
16
+
17
+ afterEach(() => {
18
+ if (savedKill === undefined) delete process.env[KILL]
19
+ else process.env[KILL] = savedKill
20
+ })
21
+
22
+ describe('gating', () => {
23
+ test('ON by default — normalizes with no env vars set', () => {
24
+ expect(ttsNormalizeEnabled()).toBe(true)
25
+ expect(normalizeForTts('**bold** $5.20')).toBe('bold five dollars twenty')
26
+ })
27
+
28
+ test('kill switch → byte-identical pass-through', () => {
29
+ process.env[KILL] = '1'
30
+ const input = '**bold** and $5.20 🚀 https://github.com/x'
31
+ expect(normalizeForTts(input)).toBe(input)
32
+ expect(ttsNormalizeEnabled()).toBe(false)
33
+ })
34
+
35
+ test('kill switch accepts "true"', () => {
36
+ process.env[KILL] = 'true'
37
+ expect(ttsNormalizeEnabled()).toBe(false)
38
+ })
39
+
40
+ test('empty input stays empty', () => {
41
+ expect(normalizeForTts('')).toBe('')
42
+ })
43
+ })
44
+
45
+ describe('markdown', () => {
46
+ test('bold/italic/strike markers removed', () => {
47
+ expect(normalizeForTts('**bold** and *italic* and ~~gone~~')).toBe(
48
+ 'bold and italic and gone',
49
+ )
50
+ })
51
+
52
+ test('headings become plain text', () => {
53
+ expect(normalizeForTts('## Status report\nAll good')).toBe(
54
+ 'Status report All good',
55
+ )
56
+ })
57
+
58
+ test('links become link text', () => {
59
+ expect(normalizeForTts('see [the docs](https://example.com/a/b) now')).toBe(
60
+ 'see the docs now',
61
+ )
62
+ })
63
+
64
+ test('list markers dropped', () => {
65
+ expect(normalizeForTts('- first\n- second')).toBe('first second')
66
+ })
67
+
68
+ test('blockquote markers dropped', () => {
69
+ expect(normalizeForTts('> quoted line')).toBe('quoted line')
70
+ })
71
+
72
+ test('code fences become spoken placeholder', () => {
73
+ const out = normalizeForTts('before\n```ts\nconst x = 1\n```\nafter')
74
+ expect(out).toContain('code block omitted')
75
+ expect(out).not.toContain('const x')
76
+ })
77
+
78
+ test('inline code read as-is without backticks, content untouched', () => {
79
+ expect(normalizeForTts('run `deploy_5km_run` now')).toBe(
80
+ 'run deploy_5km_run now',
81
+ )
82
+ })
83
+
84
+ test('unterminated fence swallows to end-of-input (deliberate)', () => {
85
+ // An opening ``` with no close drops everything after it — reading
86
+ // half a code block aloud is worse than omitting it. Pinned as the
87
+ // intended trade-off.
88
+ const out = normalizeForTts('before\n```ts\nconst secret = 1\nmore code')
89
+ expect(out).toBe('before code block omitted.')
90
+ })
91
+
92
+ test('tables summarized as table omitted', () => {
93
+ const out = normalizeForTts('| a | b |\n|---|---|\n| 1 | 2 |\n')
94
+ expect(out).toContain('table omitted')
95
+ expect(out).not.toContain('|')
96
+ })
97
+ })
98
+
99
+ describe('numbers', () => {
100
+ test('currency $5.20 → five dollars twenty', () => {
101
+ expect(normalizeForTts('that costs $5.20 today')).toBe(
102
+ 'that costs five dollars twenty today',
103
+ )
104
+ })
105
+
106
+ test('whole-dollar and singular', () => {
107
+ expect(normalizeForTts('$1 fee')).toBe('one dollar fee')
108
+ expect(normalizeForTts('$300 total')).toBe('three hundred dollars total')
109
+ })
110
+
111
+ test('percent 12% → twelve percent', () => {
112
+ expect(normalizeForTts('up 12% overnight')).toBe('up twelve percent overnight')
113
+ })
114
+
115
+ test('ordinal 3rd → third', () => {
116
+ expect(normalizeForTts('the 3rd item and the 21st day')).toBe(
117
+ 'the third item and the twenty-first day',
118
+ )
119
+ })
120
+
121
+ test('time 14:30 spoken', () => {
122
+ expect(normalizeForTts('meet at 14:30 sharp')).toBe(
123
+ 'meet at fourteen thirty sharp',
124
+ )
125
+ })
126
+
127
+ test('ISO date spoken', () => {
128
+ expect(normalizeForTts('due 2026-07-04 ok')).toBe(
129
+ 'due July fourth two thousand twenty-six ok',
130
+ )
131
+ })
132
+
133
+ test('phone-like digit run read as digits', () => {
134
+ expect(normalizeForTts('call 0412 345 678 now')).toBe(
135
+ 'call zero four one two three four five six seven eight now',
136
+ )
137
+ })
138
+
139
+ test('international +-prefixed number read as digits', () => {
140
+ expect(normalizeForTts('call +61 412 345 678')).toBe(
141
+ 'call plus six one four one two three four five six seven eight',
142
+ )
143
+ })
144
+
145
+ test('ordinary space-separated numbers stay untouched', () => {
146
+ expect(normalizeForTts('scores were 1024 2048 4096 today')).toBe(
147
+ 'scores were 1024 2048 4096 today',
148
+ )
149
+ })
150
+
151
+ test('short digit groups without phone prefix stay untouched', () => {
152
+ // The unit pass may expand a trailing unit, but the digit groups
153
+ // themselves must never be read digit-by-digit.
154
+ const out = normalizeForTts('12 34 56 78 items')
155
+ expect(out).toBe('12 34 56 78 items')
156
+ })
157
+
158
+ test('currency with thousands separator: $1,000 → one thousand dollars', () => {
159
+ expect(normalizeForTts('paid $1,000 up front')).toBe(
160
+ 'paid one thousand dollars up front',
161
+ )
162
+ expect(normalizeForTts('$12,345.67 total')).toBe(
163
+ 'twelve thousand three hundred forty-five dollars sixty-seven total',
164
+ )
165
+ })
166
+
167
+ test('odd cents ($5.203) left unchanged rather than half-read', () => {
168
+ expect(normalizeForTts('value $5.203 today')).toBe('value $5.203 today')
169
+ })
170
+
171
+ test('HH:MM:SS left unchanged (no dangling seconds)', () => {
172
+ expect(normalizeForTts('at 12:34:56 sharp')).toBe('at 12:34:56 sharp')
173
+ })
174
+
175
+ test('units expand: 5km, 10GB, 500ms', () => {
176
+ expect(normalizeForTts('ran 5km')).toBe('ran five kilometres')
177
+ expect(normalizeForTts('used 10GB')).toBe('used ten gigabytes')
178
+ expect(normalizeForTts('took 500ms')).toBe('took five hundred milliseconds')
179
+ })
180
+
181
+ test('identifiers with digits are never touched', () => {
182
+ expect(normalizeForTts('rename class5 and my5thing')).toBe(
183
+ 'rename class5 and my5thing',
184
+ )
185
+ })
186
+ })
187
+
188
+ describe('URLs', () => {
189
+ test('bare URL → spoken domain', () => {
190
+ expect(normalizeForTts('see https://github.com/switchroom/switchroom for code')).toBe(
191
+ 'see github dot com link for code',
192
+ )
193
+ })
194
+
195
+ test('www prefix dropped from spoken domain', () => {
196
+ expect(normalizeForTts('at https://www.example.org/x')).toBe(
197
+ 'at example dot org link',
198
+ )
199
+ })
200
+
201
+ test('IP-host URL degrades to a link', () => {
202
+ expect(normalizeForTts('at http://127.0.0.1:8080/x ok')).toBe('at a link ok')
203
+ })
204
+ })
205
+
206
+ describe('emoji', () => {
207
+ test('common emoji spoken via map', () => {
208
+ expect(normalizeForTts('great work 👍')).toBe('great work thumbs up')
209
+ })
210
+
211
+ test('other emoji stripped', () => {
212
+ expect(normalizeForTts('launch 🚀 now 🎉')).toBe('launch now')
213
+ })
214
+ })
215
+
216
+ describe('symbols', () => {
217
+ test('& → and, @ → at, # → hash', () => {
218
+ expect(normalizeForTts('cats & dogs')).toBe('cats and dogs')
219
+ expect(normalizeForTts('ping @ken')).toBe('ping at ken')
220
+ expect(normalizeForTts('see #general')).toBe('see hash general')
221
+ })
222
+
223
+ test('prose slash spoken', () => {
224
+ expect(normalizeForTts('yes/no answer')).toBe('yes slash no answer')
225
+ })
226
+
227
+ test('~ before a number → about', () => {
228
+ expect(normalizeForTts('~5 minutes left')).toBe('about 5 minutes left')
229
+ })
230
+ })
231
+
232
+ describe('conservatism', () => {
233
+ test('plain prose passes through unchanged', () => {
234
+ const input = 'The build finished and everything looks good.'
235
+ expect(normalizeForTts(input)).toBe(input)
236
+ })
237
+
238
+ test('deterministic: same input, same output', () => {
239
+ const input = '## Hi\n**bold** $5.20 at 14:30 👍 https://github.com/a'
240
+ expect(normalizeForTts(input)).toBe(normalizeForTts(input))
241
+ })
242
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { maybeRotate, TURNS_JSONL_MAX_BYTES, type RotateFs } from '../gateway/turns-jsonl-rotate.js'
4
+
5
+ describe('maybeRotate — turns.jsonl size cap', () => {
6
+ const mkFs = (size: number | undefined) => {
7
+ const rename = vi.fn()
8
+ const fs: RotateFs = { statSize: () => size, rename }
9
+ return { fs, rename }
10
+ }
11
+
12
+ it('does not rotate below the cap', () => {
13
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES - 1)
14
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
15
+ expect(rename).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('rotates at/over the cap → renames to <path>.1', () => {
19
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES)
20
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(true)
21
+ expect(rename).toHaveBeenCalledWith('/state/agent/turns.jsonl', '/state/agent/turns.jsonl.1')
22
+ })
23
+
24
+ it('absent file (statSize undefined) → no rotation', () => {
25
+ const { fs, rename } = mkFs(undefined)
26
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
27
+ expect(rename).not.toHaveBeenCalled()
28
+ })
29
+
30
+ it('rotation is bounded to one generation (overwrites <path>.1)', () => {
31
+ // A second rotation renames onto the same .1 target — no unbounded
32
+ // accumulation of generations.
33
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES * 3)
34
+ maybeRotate('/a/turns.jsonl', fs)
35
+ maybeRotate('/a/turns.jsonl', fs)
36
+ expect(rename).toHaveBeenNthCalledWith(1, '/a/turns.jsonl', '/a/turns.jsonl.1')
37
+ expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
38
+ })
39
+ })
@@ -84,6 +84,18 @@ describe('classifyRejection — benign Telegram 400s', () => {
84
84
  const err = grammyError(400, 'Bad Request: MESSAGE IS NOT MODIFIED: blah')
85
85
  expect(classifyRejection(err)).toBe('log_only')
86
86
  })
87
+
88
+ it('returns "log_only" for pin-rights 400 "not enough rights to manage pinned messages" (marko 2026-07-01)', () => {
89
+ // The bot is not an admin in a supergroup, so the auto status-pin
90
+ // driver's pinChatMessage returns this 400. It was crashing the whole
91
+ // gateway via a leaked fire-and-forget rejection. Auto-pin is cosmetic —
92
+ // a missing pin right must never be fatal.
93
+ const err = grammyError(
94
+ 400,
95
+ 'Bad Request: not enough rights to manage pinned messages in the chat',
96
+ )
97
+ expect(classifyRejection(err)).toBe('log_only')
98
+ })
87
99
  })
88
100
 
89
101
  describe('classifyRejection — genuine errors still crash', () => {
@@ -15,6 +15,7 @@
15
15
  import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
+ buildVaultGrantApprovedCardText,
18
19
  buildVaultGrantDeniedInbound,
19
20
  buildVaultSaveCompletedInbound,
20
21
  buildVaultSaveFailedInbound,
@@ -22,6 +23,7 @@ import {
22
23
  type VaultGrantInboundContext,
23
24
  type VaultSaveInboundContext,
24
25
  } from '../gateway/vault-grant-inbound-builders.js'
26
+ import { richMessage } from '../rich-send.js'
25
27
 
26
28
  const FIXED_NOW = 1_700_000_000_000
27
29
 
@@ -40,6 +42,58 @@ const CTX_WRITE: VaultGrantInboundContext = {
40
42
  ttl_seconds: 7 * 86400,
41
43
  }
42
44
 
45
+ describe('buildVaultGrantApprovedCardText — the [object Object] regression guard', () => {
46
+ const BASE = {
47
+ agentEscaped: 'gymbro',
48
+ scope: 'read' as const,
49
+ key: 'fatsecret/credentials',
50
+ days: 30,
51
+ grantId: 'vg_a1b2c3',
52
+ }
53
+
54
+ it('renders the full confirmation as ONE markdown string wrapped once — no [object Object]', () => {
55
+ // This is the whole point of the helper: the card text is built as a
56
+ // single string and passed to a single richMessage(...) call. The bug
57
+ // (fixed here) was `"prefix " + richMessage("suffix")`, which coerces
58
+ // the {markdown} object and prints "[object Object]".
59
+ const text = buildVaultGrantApprovedCardText(BASE)
60
+ const rich = richMessage(text)
61
+ expect(rich.markdown).toBe(text)
62
+ expect(rich.markdown).not.toContain('[object Object]')
63
+ expect(rich.markdown).toContain('vg_a1b2c3') // grant id present
64
+ })
65
+
66
+ it('includes agent, scope, key, days, and the grant id', () => {
67
+ const text = buildVaultGrantApprovedCardText(BASE)
68
+ expect(text).toContain('**gymbro**')
69
+ expect(text).toContain('read access')
70
+ expect(text).toContain('`fatsecret/credentials`')
71
+ expect(text).toContain('for 30d')
72
+ expect(text).toContain('(grant `vg_a1b2c3`)')
73
+ })
74
+
75
+ it('appends the footer when provided, omits it otherwise', () => {
76
+ const withFooter = buildVaultGrantApprovedCardText({
77
+ ...BASE,
78
+ footer: '\n_Approver verified by Telegram identity._',
79
+ })
80
+ expect(withFooter).toContain('_Approver verified by Telegram identity._')
81
+ // still one clean string
82
+ expect(withFooter).not.toContain('[object Object]')
83
+
84
+ const noFooter = buildVaultGrantApprovedCardText(BASE)
85
+ expect(noFooter.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
86
+ })
87
+
88
+ it('DOCUMENTS the original bug: bare-string + richMessage() coerces to [object Object]', () => {
89
+ // Demonstrates why the fix matters — if a future edit reintroduces the
90
+ // "concatenate a string onto the richMessage object" pattern, the result
91
+ // contains the literal "[object Object]". The helper above avoids it.
92
+ const buggy = '✅ Granted access. ' + (richMessage('(grant `vg_x`)') as unknown as string)
93
+ expect(buggy).toContain('[object Object]')
94
+ })
95
+ })
96
+
43
97
  describe('buildVaultGrantApprovedInbound', () => {
44
98
  it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
45
99
  const msg = buildVaultGrantApprovedInbound({