switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -34,6 +34,19 @@ const gatewaySrc =
34
34
  // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
35
35
  readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
36
36
 
37
+ /**
38
+ * #3627 extracted the prompt COPY into `buildAccessPassphrasePromptText` and
39
+ * the prompt SEND into `sendAccessPassphrasePrompt`, so both the first prompt
40
+ * and the wrong-passphrase re-prompt share one implementation. The pins below
41
+ * anchor on those two blocks instead of the inline text they replaced.
42
+ */
43
+ const promptBuilder =
44
+ gatewaySrc
45
+ .split('export function buildAccessPassphrasePromptText')[1]
46
+ ?.split('export function createCallbackQueryHandlers')[0] ?? ''
47
+ const promptSender =
48
+ gatewaySrc.split('async function sendAccessPassphrasePrompt')[1]?.split('\n/**')[0] ?? ''
49
+
37
50
  describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
38
51
  it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
39
52
  // fails when: the new PendingVaultOp kind is dropped. The
@@ -54,8 +67,11 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
54
67
  expect(approveBlock).toMatch(/pendingVaultOps\.set/)
55
68
  expect(approveBlock).toMatch(/passphrase-for-access-approve/)
56
69
  // Card text must invite a passphrase reply, not punt to a
57
- // /vault unlock detour.
58
- expect(approveBlock).toMatch(/Reply with your passphrase/i)
70
+ // /vault unlock detour. #3627 moved the body into the shared
71
+ // `buildAccessPassphrasePromptText` builder, so the copy pin lives
72
+ // there now; the approve block must still route into it.
73
+ expect(approveBlock).toMatch(/sendAccessPassphrasePrompt\(/)
74
+ expect(promptBuilder).toMatch(/Reply with your passphrase/i)
59
75
  // The "ask the agent to re-issue the request card" copy belonged
60
76
  // to the pre-fix path. Should be gone from the cache-miss branch.
61
77
  expect(approveBlock).not.toMatch(/ask the agent to re-issue the request card/)
@@ -67,12 +83,14 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
67
83
  // edit fires no notification and stays stapled to the card's old
68
84
  // position, so a busy topic buries it and the operator never sees
69
85
  // the passphrase ask (the reported v0.16.45 admin-key miss). The
70
- // prompt must be a fresh `sendRichMessage` below.
71
- const approveBlock =
72
- gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
86
+ // prompt must be a fresh `sendRichMessage` (#3627: from the shared
87
+ // `sendAccessPassphrasePrompt` helper, used by BOTH the first
88
+ // prompt and the wrong-passphrase re-prompt).
73
89
  // A distinct passphrase-prompt send exists (verb-tagged).
74
- expect(approveBlock).toMatch(/sendRichMessage\(pending\.chat_id, richMessage\(promptText\)/)
75
- expect(approveBlock).toMatch(/vault_request_access\.passphrase_prompt/)
90
+ expect(promptSender).toMatch(/sendRichMessage\(target\.chat_id, richMessage\(promptText\)/)
91
+ expect(promptSender).toMatch(/vault_request_access\.passphrase_prompt/)
92
+ // And it is never an in-place edit of the original card.
93
+ expect(promptSender).not.toMatch(/editMessageText/)
76
94
  })
77
95
 
78
96
  it('passphrase prompt renders via richMessage — no raw literal-markdown edit', () => {
@@ -86,25 +104,26 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
86
104
  const approveBlock =
87
105
  gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
88
106
  // The prompt text is assembled once and wrapped in richMessage.
89
- expect(approveBlock).toMatch(/const promptText =/)
107
+ expect(promptSender).toMatch(/const promptText = buildAccessPassphrasePromptText\(spec\)/)
90
108
  // Regression guard: the old raw-string admin-only edit copy is gone.
91
109
  expect(approveBlock).not.toMatch(/requires your vault passphrase to grant/)
92
110
  // Regression guard: no string-concatenated richMessage() object
93
- // (the "[object Object]" bug) remains.
94
- expect(approveBlock).not.toMatch(/\+\s*\n\s*richMessage\(/)
111
+ // (the "[object Object]" bug) remains — anywhere in the vault
112
+ // callback source, not just the approve block (#3627 fixed the
113
+ // last surviving instance, the standing-ACL resolution edit).
114
+ expect(gatewaySrc).not.toMatch(/\+\s*\n\s*richMessage\(/)
95
115
  })
96
116
 
97
117
  it('passphrase prompt is attention-grabbing and does NOT suppress notifications', () => {
98
118
  // fails when: the prompt loses its strong header or someone adds
99
119
  // disable_notification to it. The whole point of the fix is that
100
120
  // the operator gets PINGED — a silent prompt is the bug.
101
- const approveBlock =
102
- gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
103
- expect(approveBlock).toMatch(/ACTION NEEDED: passphrase required/)
121
+ // #3627: 🚨 (not ⚠️) leads the header — this blocks an approval the
122
+ // operator already tapped, so it outranks a generic warning.
123
+ expect(promptBuilder).toMatch(/\*\*🚨🔐 ACTION NEEDED: passphrase required\*\*/)
124
+ expect(promptBuilder).not.toMatch(/⚠️🔐 ACTION NEEDED/)
104
125
  // The send options for the prompt must not carry disable_notification.
105
- const promptSend =
106
- approveBlock.split('const promptText =')[1]?.split('return')[0] ?? ''
107
- expect(promptSend).not.toMatch(/disable_notification/)
126
+ expect(promptSender).not.toMatch(/disable_notification/)
108
127
  })
109
128
 
110
129
  it('passphrase intercept deletes the chat message and resumes mint', () => {
@@ -140,20 +159,22 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
140
159
  expect(handlerBlock).toMatch(/editMessageText/)
141
160
  })
142
161
 
143
- it('mint failure (e.g. wrong passphrase) edits the card; does not silent-drop', () => {
162
+ it('mint failure (non-passphrase) edits the card; does not silent-drop', () => {
144
163
  // fails when: performVaultAccessApproval's error branch returns
145
- // without editing the card. Without the edit, a wrong-passphrase
164
+ // without editing the card. Without the edit, a failed mint
146
165
  // attempt leaves the locked-vault prompt on screen forever and
147
166
  // the operator can't tell whether the system saw their reply.
148
167
  //
149
168
  // Anchor: performVaultAccessApproval's `result.kind === 'error'`
150
- // branch.
169
+ // branch. #3627: a PASSPHRASE-MISMATCH error now returns early for
170
+ // the retry loop; everything else keeps the terminal edit + drop.
151
171
  const mintHelper =
152
172
  gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('async function handleVaultRequestAccessCallback')[0] ?? ''
153
173
  expect(mintHelper).toMatch(/result\.kind === 'error'/)
154
174
  // After error: card edited AND pending entry dropped (no
155
- // zombie staged request).
156
- expect(mintHelper).toMatch(/editMessageText[\s\S]{0,400}mint_grant failed/)
175
+ // zombie staged request). The edit goes through `editResolvedCard`,
176
+ // which falls back to a fresh message when the edit itself fails.
177
+ expect(mintHelper).toMatch(/editResolvedCard[\s\S]{0,400}mint_grant failed/)
157
178
  expect(mintHelper).toMatch(/pendingVaultRequestAccesses\.delete/)
158
179
  })
159
180
  })
@@ -1,11 +1,15 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  createWorkerActivityFeed,
4
+ renderWorkerActivity,
4
5
  type BotApiForWorkerFeed,
5
6
  type WorkerActivityView,
6
7
  } from '../worker-activity-feed.js'
7
8
  import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
8
- import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
9
+ import { STATUS_CARD_CHAR_BUDGET, WORKER_STEP_INDENT } from '../status-no-truncate.js'
10
+ import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
11
+ import { richMessage } from '../rich-send.js'
12
+ import { parseRichEntities, validateRichMarkdown } from './rich-markdown-oracle.js'
9
13
  import { createSendGate, isSendGateShed, type Clock } from '../send-gate.js'
10
14
 
11
15
  /**
@@ -1235,3 +1239,231 @@ describe('worker numbering — stable per-card ordinals end to end (#3298)', ()
1235
1239
  expect(h.edits.length).toBe(landed)
1236
1240
  })
1237
1241
  })
1242
+
1243
+ // ── Per-worker step indent on the combined card ───────────────────────────────
1244
+ //
1245
+ // The multi-worker card put its `✓`/`→` step lines at the SAME left margin as
1246
+ // the numbered worker header lines, so where one worker ended and the next
1247
+ // began was invisible at a glance (operator report, 2026-07). Steps now nest
1248
+ // one level under their worker.
1249
+ //
1250
+ // The indent MUST be a U+2800 run. Card bodies go to Telegram as raw GFM
1251
+ // markdown (`richMessage` → `sendRichMessage` / `editMessageText({ markdown })`,
1252
+ // #2669) and are parsed server-side by a CommonMark/GFM-family parser that
1253
+ // LEFT-TRIMS a leading whitespace run — ASCII *and* Unicode. #3662 shipped three
1254
+ // U+00A0 and was inert: the bytes reached the Bot API intact and the card still
1255
+ // rendered flat, because U+00A0 is category Zs. U+2800 BRAILLE PATTERN BLANK is
1256
+ // category So — zero ink, non-zero width, not whitespace to any trimming rule —
1257
+ // and was live-verified on a phone on 2026-07-26.
1258
+ //
1259
+ // So these tests assert the indent's CHARACTER PROPERTY as well as its bytes: a
1260
+ // byte-only assertion is exactly what let #3662 ship green while broken.
1261
+ describe('combined worker card — steps indent under their worker (U+2800)', () => {
1262
+ /** The indent glyph. */
1263
+ const BLANK = '\u2800'
1264
+ /** The #3666 pinned-bar collapse separator — TRAILING, structurally distinct. */
1265
+ const NBSP = '\u00A0'
1266
+ const rowsFor = (n: number) =>
1267
+ Array.from({ length: n }, (_, i) => ({
1268
+ description: `worker task ${i + 1}`,
1269
+ elapsedMs: 60_000 * (i + 1),
1270
+ toolCount: 3,
1271
+ ordinal: i + 1,
1272
+ currentStep: `w${i + 1} step b`,
1273
+ historyLines: [`w${i + 1} step a`, `w${i + 1} step b`],
1274
+ }))
1275
+
1276
+ /** Card lines as sent (the body is hard-break joined by stackCardLines). */
1277
+ const linesOf = (body: string) => body.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
1278
+
1279
+ it('every step line starts with the U+2800 indent; header and chrome lines do not', () => {
1280
+ const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1281
+ const lines = linesOf(body)
1282
+ const stepLines = lines.filter((l) => l.includes('✓ w') || l.includes('→ w'))
1283
+ const headerLines = lines.filter((l) => /\*\*\d\. worker task/.test(l))
1284
+
1285
+ expect(stepLines.length).toBe(6) // 3 workers × 2 steps
1286
+ expect(headerLines.length).toBe(3)
1287
+ // Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 ×3;
1288
+ // a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
1289
+ // #3662 shipped, and so does flat output with no prefix at all.
1290
+ for (const l of stepLines) {
1291
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
1292
+ expect(l.startsWith(BLANK)).toBe(true)
1293
+ expect(l.startsWith(' ')).toBe(false)
1294
+ expect(l.startsWith(NBSP)).toBe(false)
1295
+ }
1296
+ // Workers stay at the left margin so the nesting reads as nesting.
1297
+ for (const l of headerLines) expect(l.startsWith(BLANK)).toBe(false)
1298
+ expect(lines[0].startsWith('🛠')).toBe(true)
1299
+ })
1300
+
1301
+ it('golden body — each worker header is immediately followed by ITS indented steps', () => {
1302
+ // The assertions above are set-membership only: they count step lines and
1303
+ // header lines and check prefixes independently, so a refactor that emitted
1304
+ // all three headers and THEN all six steps — nesting completely destroyed —
1305
+ // would still pass every one of them. This pins the full line ORDER, which
1306
+ // is the actual thing the fix is for: a step must sit under its own worker.
1307
+ const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1308
+ const I = WORKER_STEP_INDENT
1309
+ // `S` is the pinned-bar collapse separator (#3666): every line that is
1310
+ // followed by a hard break carries one trailing U+00A0 so the pinned-message
1311
+ // bar (which drops the newline and substitutes nothing) does not mash the
1312
+ // last glyph of a line into the first glyph of the next. It is TRAILING and
1313
+ // exactly one char — structurally distinct from the LEADING three-char
1314
+ // `WORKER_STEP_INDENT`, which is what this golden is really pinning.
1315
+ const S = COLLAPSE_SAFE_SEPARATOR
1316
+ expect(linesOf(body)).toEqual([
1317
+ `🛠 **Workers** · _3 running · oldest 3m00s · 9 tools_${S}`,
1318
+ `**1. worker task 1** _· 1m00s · 3 tools_${S}`,
1319
+ `${I}~~_✓ w1 step a_~~${S}`,
1320
+ `${I}**→ w1 step b**${S}`,
1321
+ `**2. worker task 2** _· 2m00s · 3 tools_${S}`,
1322
+ `${I}~~_✓ w2 step a_~~${S}`,
1323
+ `${I}**→ w2 step b**${S}`,
1324
+ `**3. worker task 3** _· 3m00s · 3 tools_${S}`,
1325
+ `${I}~~_✓ w3 step a_~~${S}`,
1326
+ `${I}**→ w3 step b**`, // last line: nothing follows it to collide with
1327
+ ])
1328
+ })
1329
+
1330
+ // ── The assertion that would have caught #3662 ──────────────────────────
1331
+ //
1332
+ // Every OTHER test in this file (and in pinned-card-collapse.test.ts) asserts
1333
+ // the string we hand the Bot API. #3662 shipped a U+00A0 indent that passed
1334
+ // ALL of them and still rendered dead flat on a phone, because the stripping
1335
+ // happens SERVER-SIDE: Telegram's CommonMark-family parser left-trims an
1336
+ // inline whitespace run at the head of a content line, and U+00A0 is Unicode
1337
+ // whitespace (category Zs). A byte assertion cannot observe that.
1338
+ //
1339
+ // So assert the PROPERTY that decides the outcome instead of the bytes: the
1340
+ // indent must not be composed of characters that ANY whitespace-trimming rule
1341
+ // — ASCII `/\s/`, Unicode `White_Space`, or category `Zs` — can classify as
1342
+ // whitespace. U+2800 BRAILLE PATTERN BLANK qualifies: category So (Symbol,
1343
+ // other), zero ink, non-zero width. Live-verified on a phone 2026-07-26.
1344
+ const isTrimmableWhitespace = (ch: string) =>
1345
+ /\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
1346
+
1347
+ it('WORKER_STEP_INDENT is not Unicode whitespace — a Zs indent is trimmed server-side', () => {
1348
+ const chars = [...WORKER_STEP_INDENT]
1349
+ // Pin the WIDTH too: a single blank char reads as near-flat on a phone and
1350
+ // would silently undo this fix while keeping the character class green.
1351
+ expect(chars.length).toBe(3)
1352
+ for (const ch of chars) {
1353
+ const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
1354
+ expect(
1355
+ isTrimmableWhitespace(ch),
1356
+ `WORKER_STEP_INDENT contains ${cp}, which is Unicode whitespace (\\s / White_Space / Zs). ` +
1357
+ `Telegram parses card bodies server-side and LEFT-TRIMS a leading whitespace run, so this ` +
1358
+ `indent renders FLAT on a phone — exactly the #3662 failure this test exists to catch ` +
1359
+ `(#3662 shipped three U+00A0; the bytes reached the Bot API intact and the card was still ` +
1360
+ `flat). The indent must be a zero-ink glyph that is NOT whitespace-categorised: use U+2800 ` +
1361
+ `BRAILLE PATTERN BLANK (category So), live-verified on a real phone 2026-07-26.`,
1362
+ ).toBe(false)
1363
+ }
1364
+ })
1365
+
1366
+ it('the whitespace predicate actually rejects every plausible wrong choice', () => {
1367
+ // A property assertion is only worth having if the property discriminates.
1368
+ // These are the characters a future edit would reach for as "a blank-looking
1369
+ // indent"; every one is ASCII space/tab or category Zs, and every one is
1370
+ // left-trimmed server-side.
1371
+ const flatOnAPhone = [
1372
+ ' ', // ASCII SPACE
1373
+ '\t', // TAB
1374
+ '\u00A0', // NO-BREAK SPACE — what #3662 shipped
1375
+ '\u2002', // EN SPACE
1376
+ '\u2003', // EM SPACE
1377
+ '\u2007', // FIGURE SPACE
1378
+ '\u200A', // HAIR SPACE
1379
+ '\u3000', // IDEOGRAPHIC SPACE
1380
+ ]
1381
+ for (const bad of flatOnAPhone) expect(isTrimmableWhitespace(bad)).toBe(true)
1382
+ // …and it accepts the one we ship.
1383
+ expect(isTrimmableWhitespace('\u2800')).toBe(false)
1384
+ })
1385
+
1386
+ it('WORKER_STEP_INDENT is exactly three U+2800 BRAILLE PATTERN BLANK', () => {
1387
+ // Byte pin, the companion to the property test above: that one says "not
1388
+ // whitespace", this one says "the specific glyph we live-tested".
1389
+ expect(WORKER_STEP_INDENT).toBe('\u2800'.repeat(3))
1390
+ expect(/^[\u2800]+$/.test(WORKER_STEP_INDENT)).toBe(true)
1391
+ // And not a GFM block-structure lead-in: a leading `·` was also tried live
1392
+ // and Telegram promoted it to a real list bullet, which breaks the
1393
+ // `stackCardLines` precondition that card lines are never block-structure
1394
+ // lines (card-format.ts) — the next worker header gets absorbed as a lazy
1395
+ // continuation.
1396
+ expect(/^[-*+>|#·]/.test(WORKER_STEP_INDENT)).toBe(false)
1397
+ })
1398
+
1399
+ it('the `starting…` placeholder line is indented too', () => {
1400
+ const body = renderCombinedWorkerFeed(
1401
+ [
1402
+ { description: 'fresh worker', elapsedMs: 1000, toolCount: 0, ordinal: 1, currentStep: '' },
1403
+ ...rowsFor(1),
1404
+ ],
1405
+ { maxRows: 8 },
1406
+ )!
1407
+ const starting = linesOf(body).find((l) => l.includes('starting…'))!
1408
+ expect(starting).toBeDefined()
1409
+ expect(starting.startsWith(WORKER_STEP_INDENT)).toBe(true)
1410
+ expect(starting.startsWith(BLANK)).toBe(true)
1411
+ })
1412
+
1413
+ it('the indent survives the real outbound guard chain byte-for-byte and stays parseable', () => {
1414
+ const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1415
+ // `richMessage` is the ONE adapter every `{ markdown }` wire send funnels
1416
+ // through (rich-send.ts) — it must not escape, strip, or rewrite the indent.
1417
+ const wire = richMessage(body).markdown
1418
+ expect(wire).toBe(body)
1419
+ expect(wire.includes(`${WORKER_STEP_INDENT}**→ w1 step b**`)).toBe(true)
1420
+ expect(wire.includes(`${BLANK}**→ w1 step b**`)).toBe(true)
1421
+ // Adding the indent introduced no fence/emphasis corruption.
1422
+ //
1423
+ // Scope, deliberately understated: `validateRichMarkdown` checks fence
1424
+ // balance, unterminated inline code/links, and emphasis pairing ONLY (see
1425
+ // tests/rich-markdown-oracle.ts). It models NEITHER leading-whitespace
1426
+ // stripping NOR indented code blocks, so a regression to four ASCII spaces
1427
+ // would ALSO return `[]` here. This is NOT rendering evidence and must not
1428
+ // be read as proof that the indent survives Telegram's parser — that
1429
+ // question is server-side and unobservable from this repo (see the honest
1430
+ // limit note on WORKER_STEP_INDENT in status-no-truncate.ts). The byte
1431
+ // assertions above are what discriminate an ASCII indent.
1432
+ expect(validateRichMarkdown(wire)).toEqual([])
1433
+ // …and the indent stays OUTSIDE the markdown spans — it must not land
1434
+ // inside an emphasis run (which would style the blanks). Note this checks
1435
+ // OUR OWN capture regexes in the oracle, not Telegram's real entity
1436
+ // offsets; it pins where we place the indent relative to the `**`/`~~`
1437
+ // delimiters, which is the part this repo actually controls.
1438
+ const ents = parseRichEntities(wire)
1439
+ expect(ents.some((e) => e.type === 'bold' && e.text === '→ w1 step b')).toBe(true)
1440
+ expect(ents.some((e) => e.type === 'strikethrough' && e.text === '_✓ w1 step a_')).toBe(true)
1441
+ expect(ents.every((e) => !e.text.includes(BLANK))).toBe(true)
1442
+ expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
1443
+ })
1444
+
1445
+ it('the SINGLE-worker 🛠 card is untouched (no indent on its step lines)', () => {
1446
+ const single = renderWorkerActivity({
1447
+ workerId: 'w1',
1448
+ description: 'lone worker',
1449
+ latestSummary: 'step b',
1450
+ narrativeLines: ['step a', 'step b'],
1451
+ elapsedMs: 60_000,
1452
+ toolCount: 3,
1453
+ state: 'running',
1454
+ })
1455
+ expect(single).toContain('~~_✓ step a_~~')
1456
+ expect(single).toContain('**→ step b**')
1457
+ // No LEADING indent: the single-worker card has nothing to nest under, so
1458
+ // its step lines must sit flush at the left margin. Asserted per line on the
1459
+ // leading edge rather than as "no U+00A0 anywhere in the card", because
1460
+ // #3666 puts one TRAILING U+00A0 on every hard-broken line of every pinned
1461
+ // card (this one included) as the collapse separator. A regression that
1462
+ // leaked WORKER_STEP_INDENT onto these lines still fails here.
1463
+ for (const l of single.split('\n')) {
1464
+ expect(l.startsWith(BLANK)).toBe(false)
1465
+ expect(l.startsWith(NBSP)).toBe(false)
1466
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1467
+ }
1468
+ })
1469
+ })
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createWorkerActivityFeed,
4
+ stripRepeatSuffix,
5
+ repeatCountOf,
6
+ type WorkerActivityView,
7
+ type BotApiForWorkerFeed,
8
+ } from '../worker-activity-feed.js'
9
+
10
+ /**
11
+ * A worker whose steps all carry the SAME label must still visibly advance.
12
+ *
13
+ * The bug (observed live 2026-07-25, klanker): `description` is optional on
14
+ * Bash, so a sub-agent that never wrote one produced the constant label
15
+ * "Running a command" for every call. `accumulateNarrative` dropped every
16
+ * repeat as a duplicate, and the card held ONE line at a constant byte length
17
+ * for the whole job — editMessageText kept succeeding, so the surface looked
18
+ * healthy while conveying nothing. A wedged worker and a busy worker rendered
19
+ * identically.
20
+ *
21
+ * The fix counts repeats (`·×N`) instead of discarding them, gated on
22
+ * `view.toolCount` so that a re-emitted UNCHANGED view (the watcher does this
23
+ * every tick) never inflates the count.
24
+ */
25
+
26
+ function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
27
+ return {
28
+ description: 'fix the thing',
29
+ lastTool: { name: 'Bash', sanitisedArg: 'x' },
30
+ toolCount: 1,
31
+ latestSummary: 'Running a command',
32
+ elapsedMs: 10_000,
33
+ state: 'running',
34
+ ...partial,
35
+ }
36
+ }
37
+
38
+ interface FakeBot extends BotApiForWorkerFeed {
39
+ sent: Array<{ text: string }>
40
+ edits: Array<{ text: string }>
41
+ }
42
+
43
+ function makeFakeBot(): FakeBot {
44
+ let nextId = 1000
45
+ const fb: FakeBot = {
46
+ sent: [],
47
+ edits: [],
48
+ sendMessage: async (_chatId, text) => {
49
+ fb.sent.push({ text })
50
+ return { message_id: nextId++ }
51
+ },
52
+ editMessageText: async (_chatId, _messageId, text) => {
53
+ fb.edits.push({ text })
54
+ return {}
55
+ },
56
+ }
57
+ return fb
58
+ }
59
+
60
+ /** Latest rendered body for the group (last edit, else the first paint). */
61
+ function latestBody(bot: FakeBot): string {
62
+ return bot.edits.length > 0 ? bot.edits[bot.edits.length - 1].text : bot.sent[0].text
63
+ }
64
+
65
+ describe('worker feed — repeated step labels', () => {
66
+ it('renders a climbing ·×N counter instead of freezing on one line', async () => {
67
+ const bot = makeFakeBot()
68
+ let clock = 0
69
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
70
+
71
+ for (let i = 1; i <= 5; i++) {
72
+ clock += 10_000
73
+ await feed.update('w1', 'chat', view({ toolCount: i, elapsedMs: clock }))
74
+ }
75
+
76
+ const body = latestBody(bot)
77
+ expect(body).toContain('Running a command ·×5')
78
+ // …and the frozen single-count line is gone from the newest render.
79
+ expect(body).not.toMatch(/Running a command\*{0,2}\s*$/m)
80
+ })
81
+
82
+ it('does NOT inflate the count when the watcher re-emits an unchanged view', async () => {
83
+ const bot = makeFakeBot()
84
+ let clock = 0
85
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
86
+
87
+ // Same toolCount across many ticks = the SAME tool call, re-observed.
88
+ for (let i = 0; i < 6; i++) {
89
+ clock += 5_000
90
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
91
+ }
92
+
93
+ const body = latestBody(bot)
94
+ expect(body).toContain('Running a command')
95
+ expect(body).not.toContain('·×')
96
+ })
97
+
98
+ it('starts a NEW step (no counter) when the label actually changes', async () => {
99
+ const bot = makeFakeBot()
100
+ let clock = 0
101
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
102
+
103
+ clock += 10_000
104
+ await feed.update('w1', 'chat', view({ toolCount: 1, elapsedMs: clock }))
105
+ clock += 10_000
106
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
107
+ clock += 10_000
108
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'Running git status', elapsedMs: clock }))
109
+
110
+ const body = latestBody(bot)
111
+ expect(body).toContain('Running a command ·×2')
112
+ expect(body).toContain('Running git status')
113
+ expect(body).not.toContain('Running git status ·×')
114
+ })
115
+
116
+ it('keeps the non-adjacent A,B,A dedup (no duplicate step lines)', async () => {
117
+ const bot = makeFakeBot()
118
+ let clock = 0
119
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
120
+
121
+ const steps = ['Reading alpha.ts', 'Running grep', 'Reading alpha.ts']
122
+ for (const [i, summary] of steps.entries()) {
123
+ clock += 10_000
124
+ await feed.update('w1', 'chat', view({ toolCount: i + 1, latestSummary: summary, elapsedMs: clock }))
125
+ }
126
+
127
+ const body = latestBody(bot)
128
+ expect((body.match(/Reading alpha\.ts/g) ?? []).length).toBe(1)
129
+ // Non-adjacent repeats are dropped, not counted — the A,B,A duplication
130
+ // guard predates this change and stays intact.
131
+ expect(body).not.toContain('Reading alpha.ts ·×')
132
+ })
133
+ })
134
+
135
+ describe('repeat-suffix helpers', () => {
136
+ it('round-trips the marker', () => {
137
+ expect(stripRepeatSuffix('Running a command ·×12')).toBe('Running a command')
138
+ expect(stripRepeatSuffix('Running a command')).toBe('Running a command')
139
+ expect(repeatCountOf('Running a command')).toBe(1)
140
+ expect(repeatCountOf('Running a command ·×12')).toBe(12)
141
+ })
142
+
143
+ it('does not mistake ordinary text for a marker', () => {
144
+ expect(stripRepeatSuffix('Comparing 3 ×2 grids')).toBe('Comparing 3 ×2 grids')
145
+ expect(repeatCountOf('Comparing 3 ×2 grids')).toBe(1)
146
+ })
147
+ })