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
@@ -11,6 +11,7 @@ import {
11
11
  bindingWindow,
12
12
  formatRelative,
13
13
  formatAbsolute,
14
+ formatStatusTime,
14
15
  fmtPct,
15
16
  recommendation,
16
17
  renderAuthSnapshotFormat2,
@@ -133,6 +134,14 @@ describe('fmtPct', () => {
133
134
  expect(fmtPct(8.6)).toBe('9%');
134
135
  expect(fmtPct(99.6)).toBe('100%');
135
136
  });
137
+ it('CLAMPS the displayed value to 100% — never shows over 100 (trust)', () => {
138
+ // Anthropic returns a decimal that can exceed 1.0; quota-check multiplies by
139
+ // 100 → e.g. 1.01 → 101. The displayed number must clamp to 100%.
140
+ expect(fmtPct(101)).toBe('100%');
141
+ expect(fmtPct(100.4)).toBe('100%');
142
+ expect(fmtPct(250)).toBe('100%');
143
+ expect(fmtPct(1.01 * 100)).toBe('100%');
144
+ });
136
145
  });
137
146
 
138
147
  // ── formatAbsolute ───────────────────────────────────────────────────
@@ -149,6 +158,52 @@ describe('formatAbsolute', () => {
149
158
  });
150
159
  });
151
160
 
161
+ // ── formatStatusTime (date shown only when NOT today, in user tz) ─────
162
+ describe('formatStatusTime', () => {
163
+ // Fixed injected now + fixed tz → fully deterministic (no wall-clock).
164
+ // 2026-05-15T05:00:00Z = Fri 15 May 3:00 PM in Australia/Melbourne (AEST,
165
+ // UTC+10). May is winter there → AEST, not AEDT — the tz database handles it.
166
+ const MEL = 'Australia/Melbourne';
167
+ const NOW_MEL = new Date('2026-05-15T05:00:00Z'); // Fri 3:00 PM Melbourne
168
+
169
+ it('shows TIME ONLY when the target is the same calendar day (user tz)', () => {
170
+ // 2026-05-15T13:00:00Z = Fri 11:00 PM Melbourne — same Melbourne day as now.
171
+ const out = formatStatusTime(new Date('2026-05-15T13:00:00Z'), NOW_MEL, MEL);
172
+ expect(out).toBe('11:00 PM');
173
+ // No weekday/date leaks on a same-day target.
174
+ expect(out).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
175
+ });
176
+
177
+ it('includes the WEEKDAY when the target is a different day (within the week)', () => {
178
+ // 2026-05-15T15:00:00Z = Sat 16 May 1:00 AM Melbourne — next Melbourne day.
179
+ const out = formatStatusTime(new Date('2026-05-15T15:00:00Z'), NOW_MEL, MEL);
180
+ expect(out).toBe('Sat 1:00 AM');
181
+ expect(out).toMatch(/^Sat\b/);
182
+ });
183
+
184
+ it('includes day-of-month + month when the target is beyond the current week', () => {
185
+ // ~9 days out: 2026-05-24T01:00:00Z = Sun 24 May 11:00 AM Melbourne.
186
+ const out = formatStatusTime(new Date('2026-05-24T01:00:00Z'), NOW_MEL, MEL);
187
+ // Intl en-US renders the weekday+day+month as "Sun, May 24".
188
+ expect(out).toBe('Sun, May 24 11:00 AM');
189
+ expect(out).toMatch(/^Sun\b/);
190
+ expect(out).toContain('May 24');
191
+ });
192
+
193
+ it('is tz-correct — same instant renders differently in UTC vs Melbourne', () => {
194
+ const instant = new Date('2026-05-15T15:00:00Z');
195
+ // In UTC that instant is still Fri 15 May 3:00 PM → same day as NOW_MEL's UTC
196
+ // wall date — but NOW in UTC terms is also 2026-05-15, so time-only.
197
+ expect(formatStatusTime(instant, NOW_MEL, 'UTC')).toBe('3:00 PM');
198
+ // In Melbourne it has crossed midnight into Saturday.
199
+ expect(formatStatusTime(instant, NOW_MEL, MEL)).toBe('Sat 1:00 AM');
200
+ });
201
+
202
+ it('returns "—" for a null target', () => {
203
+ expect(formatStatusTime(null, NOW_MEL, MEL)).toBe('—');
204
+ });
205
+ });
206
+
152
207
  // ── renderAuthSnapshotFormat2 ────────────────────────────────────────
153
208
 
154
209
  describe('renderAuthSnapshotFormat2', () => {
@@ -191,42 +246,120 @@ describe('renderAuthSnapshotFormat2', () => {
191
246
  }),
192
247
  ];
193
248
 
194
- it('renders three health-grouped sections (BLOCKED first, then HEALTHY)', () => {
249
+ // Helper: parse the GFM table rows (the lines between the header separator
250
+ // and the trailing blank line) into their cell arrays.
251
+ function tableRows(out: string): string[][] {
252
+ const lines = out.split('\n');
253
+ const sep = lines.findIndex((l) => /^\|\s*---/.test(l));
254
+ if (sep < 0) return [];
255
+ const rows: string[][] = [];
256
+ for (let i = sep + 1; i < lines.length; i++) {
257
+ const l = lines[i];
258
+ if (!l.startsWith('|')) break;
259
+ rows.push(l.split('|').slice(1, -1).map((c) => c.trim()));
260
+ }
261
+ return rows;
262
+ }
263
+
264
+ it('renders a GFM table with the State/Account/5h/7d/Status header', () => {
195
265
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
196
- // Headers present
197
266
  expect(out).toContain('🔋 **Auth — fleet status**');
198
- expect(out).toContain('🔴 **BLOCKED** (1)');
199
- expect(out).toContain('🟢 **HEALTHY** (2)');
200
- // Order: BLOCKED before HEALTHY
201
- expect(out.indexOf('🔴')).toBeLessThan(out.indexOf('🟢'));
267
+ expect(out).toContain('| State | Account | 5h | 7d | Status |');
268
+ expect(out).toContain('| --- | --- | --- | --- | --- |');
269
+ // No legacy group headers / health-section titles remain.
270
+ expect(out).not.toContain('**BLOCKED**');
271
+ expect(out).not.toContain('**HEALTHY**');
202
272
  });
203
273
 
204
- it('marks the active account with ', () => {
205
- const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
206
- expect(out).toMatch(/●\s*`you@example\.com`/);
274
+ it('sorts the ACTIVE account to the top row regardless of health group', () => {
275
+ const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
276
+ expect(rows.length).toBe(3);
277
+ // you@example.com is active + healthy; bob is blocked. Active still wins
278
+ // the top row over the blocked account.
279
+ expect(rows[0][1]).toContain('you@example.com');
207
280
  });
208
281
 
209
- it('shows "back " for blocked accounts with binding-window word', () => {
282
+ it('labels the active account with " (active)" and NO black-dot marker', () => {
210
283
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
211
- // bob@example is blocked on 7d, recovers Sun
212
- expect(out).toMatch(/bob@example\.com[\s\S]*back .* 7-day cap/);
284
+ expect(out).toContain('you@example.com (active)');
285
+ // The ⚫/● active marker is removed entirely.
286
+ expect(out).not.toContain('⚫');
287
+ expect(out).not.toContain('●');
213
288
  });
214
289
 
215
- it('puts the imminent window first on healthy/throttling rows', () => {
216
- const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
217
- // you: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
218
- // The two reset segments now render on SEPARATE lines (so the 7d
219
- // segment doesn't wrap mid-line on a narrow phone) — the imminent 5h
220
- // line must precede the 7d line, each on its own line.
221
- const lines = out.split('\n');
222
- const fiveLine = lines.findIndex((l) => l.includes('5h refills'));
223
- const sevenLine = lines.findIndex((l) => l.includes('7d resets'));
224
- expect(fiveLine).toBeGreaterThanOrEqual(0);
225
- expect(sevenLine).toBeGreaterThanOrEqual(0);
226
- // Distinct lines, 5h before 7d.
227
- expect(fiveLine).toBeLessThan(sevenLine);
228
- expect(lines[fiveLine]).not.toContain('7d resets');
229
- expect(lines[sevenLine]).not.toContain('5h refills');
290
+ it('shows the FULL email address, never truncated', () => {
291
+ const longSnaps = [
292
+ snap({ label: 'a-very-long-account-name@really-long-domain.example.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
293
+ ];
294
+ const rows = tableRows(renderAuthSnapshotFormat2(longSnaps, { now: NOW, tz: 'UTC' }));
295
+ expect(rows[0][1]).toContain('a-very-long-account-name@really-long-domain.example.com');
296
+ expect(rows[0][1]).not.toContain('');
297
+ expect(rows[0][1]).not.toContain('...');
298
+ });
299
+
300
+ it('orders non-active rows blocked-before-healthy', () => {
301
+ const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
302
+ // After the active row (you), the blocked bob must precede the healthy alice.
303
+ const bob = rows.findIndex((r) => r[1].includes('bob@example.com'));
304
+ const alice = rows.findIndex((r) => r[1].includes('alice@example.com'));
305
+ expect(bob).toBeGreaterThan(0);
306
+ expect(bob).toBeLessThan(alice);
307
+ });
308
+
309
+ it('Status column shows "back <when>" for a blocked account', () => {
310
+ const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
311
+ const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
312
+ expect(bob[4]).toMatch(/^back .* \(in .+\)$/);
313
+ });
314
+
315
+ it('Status column shows "refills <when>" for a healthy account', () => {
316
+ const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
317
+ const you = rows.find((r) => r[1].includes('you@example.com'))!;
318
+ expect(you[4]).toMatch(/^refills .* \(in .+\)$/);
319
+ });
320
+
321
+ it('NEVER displays a percentage over 100% even on an over-cap account', () => {
322
+ // Feed 1.01 (decimal) * 100 = 101 → must clamp to 100% in the cell.
323
+ const overSnaps = [
324
+ snap({
325
+ label: 'over@example.com',
326
+ isActive: true,
327
+ quota: quota({ fiveHourUtilizationPct: 101, sevenDayUtilizationPct: 250 }),
328
+ }),
329
+ ];
330
+ const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
331
+ expect(rows[0][2]).toBe('100%'); // 5h
332
+ expect(rows[0][3]).toBe('100%'); // 7d
333
+ // And the blocked state still surfaces via the emoji (🔴) + Status.
334
+ expect(rows[0][0]).toBe('🔴');
335
+ expect(rows[0][4]).toMatch(/^back/);
336
+ });
337
+
338
+ it('renders dates in the Status column only when NOT today (user tz)', () => {
339
+ // now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
340
+ // time-only; a reset on the next day shows the weekday.
341
+ const MEL = 'Australia/Melbourne';
342
+ const NOW_MEL = new Date('2026-05-15T05:00:00Z'); // Fri 3:00 PM Mel
343
+ const sameDay = [
344
+ snap({ label: 'today@example.com', isActive: true, quota: quota({
345
+ fiveHourUtilizationPct: 5,
346
+ fiveHourResetAt: new Date('2026-05-15T11:00:00Z'), // Fri 9:00 PM Mel (today)
347
+ sevenDayResetAt: new Date('2026-05-22T11:00:00Z'),
348
+ }) }),
349
+ ];
350
+ const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
351
+ expect(todayRows[0][4]).toContain('9:00 PM');
352
+ expect(todayRows[0][4]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
353
+
354
+ const nextDay = [
355
+ snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
356
+ fiveHourUtilizationPct: 5,
357
+ fiveHourResetAt: new Date('2026-05-15T15:00:00Z'), // Sat 1:00 AM Mel (tomorrow)
358
+ sevenDayResetAt: new Date('2026-05-22T11:00:00Z'),
359
+ }) }),
360
+ ];
361
+ const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
362
+ expect(tomorrowRows[0][4]).toContain('Sat 1:00 AM');
230
363
  });
231
364
 
232
365
  it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
@@ -251,9 +384,10 @@ describe('renderAuthSnapshotFormat2', () => {
251
384
  snap({ label: 'broken@x', isActive: true, quota: null, quotaError: 'HTTP 401' }),
252
385
  ];
253
386
  const out = renderAuthSnapshotFormat2(errSnaps, { now: NOW });
254
- expect(out).toContain('quota probe failed');
387
+ // Probe-failed row: ⚪ State emoji + a "probe failed (...)" Status cell.
388
+ expect(out).toContain('probe failed');
255
389
  expect(out).toContain('HTTP 401');
256
- expect(out).toContain('⚪ **UNKNOWN**');
390
+ expect(out).toContain('⚪');
257
391
  });
258
392
 
259
393
  it('renders refresh stamp when liveProbedAtMs given', () => {
@@ -300,9 +434,8 @@ describe('renderAuthSnapshotFormat2', () => {
300
434
  expect(out).not.toContain('alice@example.com');
301
435
  expect(out).not.toContain('bob@example.com');
302
436
  expect(out).not.toContain('you@example.com');
303
- // Masked fakes present (pool order from a clean cache: blocked-first
304
- // render order means bob is masked first, then alice/you in healthy).
305
- expect(out).toMatch(/`[^@<]+@example\.com`/);
437
+ // Masked fakes present plain table cells (no backtick wrap now).
438
+ expect(out).toMatch(/[^@\s<]+@example\.com/);
306
439
  });
307
440
 
308
441
  it('WITH demo, the recommendation footer masks the active label', () => {
@@ -371,6 +504,33 @@ describe('renderFallbackAnnouncement', () => {
371
504
  expect(out7).toContain('7-day limit on me@x');
372
505
  });
373
506
 
507
+ // Context-correct escaping (#2695 fix): the swap announcement renders each
508
+ // label in TWO contexts, and each demands a different treatment —
509
+ // • **bold** header / trigger-agent → escapeMarkdown (backslash-escaped, so
510
+ // an underscore can't open an emphasis run), and
511
+ // • `code span` (the old→new line, recovery line) → codeSpanSafe (LITERAL,
512
+ // because backslash escaping is inert inside a code span and would show a
513
+ // visible `old\_a\*x`).
514
+ it('escapes labels in the bold header but renders them literally in code spans (#2695)', () => {
515
+ const out = renderFallbackAnnouncement({
516
+ oldLabel: 'old_a*x',
517
+ oldQuota: KEN_5H_BLOWN,
518
+ newLabel: 'new_b*y',
519
+ newQuota: YOU_HEALTHY,
520
+ triggerAgent: 'agent_z*q',
521
+ now: NOW,
522
+ tz: 'UTC',
523
+ });
524
+ // Emphasis contexts: backslash-escaped.
525
+ expect(out).toContain('old\\_a\\*x'); // bold header "· … on old_a*x"
526
+ expect(out).toContain('agent\\_z\\*q'); // "Triggered by: agent **…**"
527
+ // Code-span contexts: literal, no stray backslash.
528
+ expect(out).toContain('`old_a*x`');
529
+ expect(out).toContain('`new_b*y`');
530
+ expect(out).not.toContain('`old\\_a\\*x`');
531
+ expect(out).not.toContain('`new\\_b\\*y`');
532
+ });
533
+
374
534
  it('names the triggering agent + recovery countdown for the old account', () => {
375
535
  const out = renderFallbackAnnouncement({
376
536
  oldLabel: 'ken@x',
@@ -858,23 +1018,20 @@ describe('#2494 Bug C — blockedReason + thin probe (out_of_credits now informa
858
1018
  // ── #2494 — row rendering: out_of_credits as informational annotation ─────────
859
1019
 
860
1020
  describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demoted)', () => {
861
- it('out_of_credits at 0% util → healthy row with informational overage annotation, NOT blocked', () => {
862
- // THE KEY CHANGE: carol scenario — 0% util, out_of_credits → healthy group
863
- // with an informational sub-line "overage off (out_of_credits)serving from quota"
1021
+ it('out_of_credits at 0% util → healthy (🟢) row, NOT blocked', () => {
1022
+ // THE KEY CHANGE: carol scenario — 0% util, out_of_credits → healthy. In the
1023
+ // table this surfaces as a 🟢 State emoji, not 🔴 the row is not blocked.
864
1024
  const out = renderAuthSnapshotFormat2(
865
1025
  [snap({ label: 'dead@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) })],
866
1026
  { now: NOW },
867
1027
  );
868
- // Must be in HEALTHY group, not BLOCKED
869
- expect(out).toContain('🟢 **HEALTHY**');
870
- expect(out).not.toContain('🔴 **BLOCKED**');
871
- // Must have informational annotation. #2669: the reason is markdown-
872
- // escaped, so the underscores in "out_of_credits" render as "out\_of\_credits".
873
- expect(out).toContain('overage off (out\\_of\\_credits) — serving from quota');
874
- // Must NOT have old blocked framing
1028
+ expect(out).toContain('| 🟢 |');
1029
+ expect(out).not.toContain('| 🔴 |');
1030
+ // Must NOT have old blocked framing.
875
1031
  expect(out).not.toContain('billing disabled');
876
1032
  expect(out).not.toContain("won't recover until billing is fixed");
877
1033
  expect(out).not.toContain('quota exhausted');
1034
+ expect(out).not.toContain('back ');
878
1035
  });
879
1036
 
880
1037
  it('out_of_credits account is a valid failover target (appears in buildSnapshotKeyboard switch buttons)', () => {
@@ -888,13 +1045,14 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
888
1045
  expect(allText).toContain('Switch fleet → carol@example.com');
889
1046
  });
890
1047
 
891
- it('quota-exhausted row shows a recovery countdown, not "billing disabled" or overage annotation', () => {
1048
+ it('quota-exhausted row shows a 🔴 + "back <when>" Status, not "billing disabled"', () => {
892
1049
  const futureReset = new Date(NOW.getTime() + 45 * 60_000);
893
1050
  const out = renderAuthSnapshotFormat2(
894
1051
  [snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
895
1052
  { now: NOW },
896
1053
  );
897
- expect(out).toContain('quota exhausted');
1054
+ expect(out).toContain('| 🔴 |');
1055
+ expect(out).toMatch(/back .* \(in 45m\)/);
898
1056
  expect(out).not.toContain('billing disabled');
899
1057
  expect(out).not.toContain('overage off');
900
1058
  });
@@ -914,7 +1072,8 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
914
1072
  [snap({ label: 'org@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 85, overageDisabledReason: 'org_level_disabled' }) })],
915
1073
  { now: NOW },
916
1074
  );
917
- expect(out).toContain('🟡 **THROTTLING**');
1075
+ // 85% util → throttling, surfaced as the 🟡 State emoji in the table.
1076
+ expect(out).toContain('| 🟡 |');
918
1077
  expect(out).not.toContain('overage off');
919
1078
  });
920
1079
 
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  cleanWorkerResultParagraph,
4
4
  formatDuration,
5
+ stackCardLines,
5
6
  stripMarkdown,
6
7
  truncate,
7
8
  } from '../card-format.js'
@@ -55,6 +56,21 @@ describe('stripMarkdown', () => {
55
56
  it('does not touch HTML-significant characters (escaping stays separate)', () => {
56
57
  expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
57
58
  })
59
+
60
+ it('F1: normalizes an em-dash on this card/narration prose surface', () => {
61
+ // The reply path scrubs em-dashes but cards/narration/PTY previews did not,
62
+ // so em-dashes leaked there. stripMarkdown now runs the same dash logic.
63
+ // A clause-joining dash becomes a full stop (never a comma — that produces
64
+ // a comma splice), with the following word recapitalized into a sentence.
65
+ expect(stripMarkdown('build done — shipping now')).toBe('build done. Shipping now')
66
+ expect(stripMarkdown('build done — Shipping now')).toBe('build done. Shipping now')
67
+ })
68
+
69
+ it('F1: preserves an em-dash inside an inline code span', () => {
70
+ // The dash normalizer masks code spans, and it runs BEFORE the backticks
71
+ // are stripped, so a dash inside `` `…` `` survives verbatim.
72
+ expect(stripMarkdown('see `a — b` token')).toBe('see a — b token')
73
+ })
58
74
  })
59
75
 
60
76
  describe('cleanWorkerResultParagraph', () => {
@@ -75,6 +91,19 @@ describe('cleanWorkerResultParagraph', () => {
75
91
  it('returns empty for whitespace/markup-only input', () => {
76
92
  expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
77
93
  })
94
+
95
+ it('F1: normalizes em-dashes in worker narration prose', () => {
96
+ const input = '## Result\n\nTests pass — ready to merge'
97
+ expect(cleanWorkerResultParagraph(input)).toBe('Result Tests pass. Ready to merge')
98
+ })
99
+
100
+ it('F1: an em-dash inside a fenced block is preserved (the block is dropped, never normalized)', () => {
101
+ // Fenced blocks are excised whole, so a dash inside one is preserved by
102
+ // removal — it never reaches (nor is mutated by) the dash normalizer, and
103
+ // the surrounding prose dash IS normalized.
104
+ const input = 'before — after\n```\nlet a = b — c\n```\ntail'
105
+ expect(cleanWorkerResultParagraph(input)).toBe('before. After tail')
106
+ })
78
107
  })
79
108
 
80
109
  describe('formatDuration', () => {
@@ -96,3 +125,53 @@ describe('escapeMarkdown / truncate', () => {
96
125
  expect(truncate('abc', 4)).toBe('abc')
97
126
  })
98
127
  })
128
+
129
+ describe('stackCardLines — card bodies stack like the reply path', () => {
130
+ // The bug this fixes: the rich-message renderer (#2669) treats a LONE `\n`
131
+ // between two non-blank lines as a SOFT break and collapses them onto one
132
+ // visual line. A card built as a list of styled prose lines and joined with
133
+ // `\n` therefore renders as a run-on blob. stackCardLines promotes every
134
+ // inter-line break to a GFM hard break (` \n`) so the lines stack — the
135
+ // same end-state the reply path reaches via normalizeParagraphBreaks.
136
+
137
+ it('promotes every inter-line break to a GFM hard break so bullets stack', () => {
138
+ const out = stackCardLines(['_✓ Reading a.ts_', '_✓ Searching memory_', '**→ List workspace**'])
139
+ expect(out).toBe('_✓ Reading a.ts_ \n_✓ Searching memory_ \n**→ List workspace**')
140
+ // No LONE `\n` survives between non-blank lines — every break is a hard break.
141
+ expect(out.replace(/ {2}\n/g, '')).not.toContain('\n')
142
+ })
143
+
144
+ it('single line is returned verbatim (no trailing hard break)', () => {
145
+ expect(stackCardLines(['**→ Reading a.ts**'])).toBe('**→ Reading a.ts**')
146
+ })
147
+
148
+ it('preserves an intentional blank-line paragraph gap', () => {
149
+ // A blank entry is a genuine `\n\n` gap (e.g. boot card header → rows);
150
+ // it must NOT become a hard-break run — the blank reconstructs the gap.
151
+ const out = stackCardLines(['🔔 **Header**', '', '_✓ row one_', '_✓ row two_'])
152
+ expect(out).toBe('🔔 **Header**\n\n_✓ row one_ \n_✓ row two_')
153
+ })
154
+
155
+ it('does not accumulate trailing spaces on a re-run (idempotent breaks)', () => {
156
+ const once = stackCardLines(['a.', 'b.', 'c.'])
157
+ // Feeding the stacked output back in (split on hard break) yields the same.
158
+ const twice = stackCardLines(once.split(' \n'))
159
+ expect(twice).toBe(once)
160
+ })
161
+
162
+ it('keeps bold, code-spans and the worker-result rule intact (only breaks change)', () => {
163
+ const out = stackCardLines([
164
+ '🛠 **Worker** · _build_',
165
+ '_✓ Running `git push`_',
166
+ '─────',
167
+ '✅ _Fixed the **bug** in `foo.ts`_',
168
+ ])
169
+ // Inline markup is untouched — bold, code spans, the rule line all survive.
170
+ expect(out).toContain('**Worker**')
171
+ expect(out).toContain('`git push`')
172
+ expect(out).toContain('Fixed the **bug** in `foo.ts`')
173
+ expect(out).toContain('─────')
174
+ // And the four lines stack (three hard breaks, no soft breaks).
175
+ expect(out.match(/ {2}\n/g)?.length).toBe(3)
176
+ })
177
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveChatIdFallback, type ChatIdAllowlist } from '../gateway/chat-id-fallback.js'
4
+
5
+ /**
6
+ * Regression tests for the sr-* chat_id routing hotfixes that shipped
7
+ * test-free, plus the stream_reply fallback parity fix.
8
+ *
9
+ * The reply / stream_reply final-answer paths call resolveChatIdFallback with a
10
+ * String()-coerced chat_id. This suite pins the pure decision:
11
+ * - Bug A — numeric coercion (String() at the callsite makes 42 match "42")
12
+ * - Bug B — executeReply falls back to the active turn's sessionChatId
13
+ * - Bug D — lastActiveTurnChatId survives the silence poke (currentTurn null)
14
+ * - stream_reply — the SAME fallback (mirrors the executeReply Bug-D test)
15
+ */
16
+ describe('resolveChatIdFallback', () => {
17
+ const access = (allowFrom: string[], groups: Record<string, unknown> = {}): ChatIdAllowlist => ({
18
+ allowFrom,
19
+ groups,
20
+ })
21
+
22
+ it('allowlisted raw chat_id passes through unchanged (tier=raw)', () => {
23
+ const r = resolveChatIdFallback('123', access(['123']), '999', '999', true)
24
+ expect(r).toEqual({ chatId: '123', tier: 'raw' })
25
+ })
26
+
27
+ it('a chat_id present in groups (not allowFrom) passes through (tier=raw)', () => {
28
+ const r = resolveChatIdFallback('-100777', access([], { '-100777': {} }), '999', '999', true)
29
+ expect(r).toEqual({ chatId: '-100777', tier: 'raw' })
30
+ })
31
+
32
+ // Bug A — numeric coercion. The model passes a JSON number; the caller
33
+ // String()-coerces before calling. A stringified numeric id matches the
34
+ // string allowlist, so it must pass through as tier=raw (no spurious
35
+ // fallback).
36
+ it('Bug A: numerically-coerced chat_id ("42") matches the string allowlist', () => {
37
+ const coerced = String(42 as unknown as number)
38
+ const r = resolveChatIdFallback(coerced, access(['42']), '999', '999', true)
39
+ expect(r).toEqual({ chatId: '42', tier: 'raw' })
40
+ })
41
+
42
+ // Bug B — executeReply fallback. Raw chat_id not allowlisted, a live turn is
43
+ // present → route to the active turn's validated sessionChatId.
44
+ it('Bug B: non-allowlisted raw + live turn → active turn sessionChatId', () => {
45
+ const r = resolveChatIdFallback('wrong-id', access(['123']), '123', '123', true)
46
+ expect(r).toEqual({ chatId: '123', tier: 'active' })
47
+ })
48
+
49
+ // Bug D — lastActiveTurnChatId survives the silence poke. currentTurn is null
50
+ // (turnSessionChatId undefined, hasLiveTurn false) because clearTurnStarted
51
+ // fired after ≥5 min silence, but the model finally replied after the poke.
52
+ // The last-known chat still routes the answer.
53
+ it('Bug D: no live turn (silence poke) → last-known turn chat', () => {
54
+ const r = resolveChatIdFallback('wrong-id', access(['123']), undefined, '123', false)
55
+ expect(r).toEqual({ chatId: '123', tier: 'last-known' })
56
+ })
57
+
58
+ // stream_reply Bug-D mirror: stream_reply is the primary final-answer path
59
+ // and now applies the SAME fallback. Identical inputs → identical decision.
60
+ it('stream_reply parity: same fallback after a silence poke (mirrors Bug D)', () => {
61
+ const r = resolveChatIdFallback('wrong-id', access(['555']), undefined, '555', false)
62
+ expect(r).toEqual({ chatId: '555', tier: 'last-known' })
63
+ })
64
+
65
+ it('no valid fallback → returns raw so assertAllowedChat throws the human error', () => {
66
+ const r = resolveChatIdFallback('wrong-id', access(['123']), 'also-wrong', 'still-wrong', true)
67
+ expect(r).toEqual({ chatId: 'wrong-id', tier: 'raw' })
68
+ })
69
+
70
+ it('active tier wins over last-known when both are set (turn present)', () => {
71
+ const r = resolveChatIdFallback('wrong-id', access(['active-chat', 'old-chat']), 'active-chat', 'old-chat', true)
72
+ expect(r).toEqual({ chatId: 'active-chat', tier: 'active' })
73
+ })
74
+ })