switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect } from 'vitest'
17
+ import { readFileSync } from 'node:fs'
17
18
  import {
18
19
  decideTurnFlush,
19
20
  isSilentFlushMarker,
@@ -21,6 +22,20 @@ import {
21
22
  endsWithSilentMarker,
22
23
  isTurnFlushSafetyEnabled,
23
24
  } from '../turn-flush-safety.js'
25
+ // Rich-message send-path primitives (Bot API 10.1, #2669/#2692). The #2798
26
+ // regression suite below reconstructs the exact gateway turn-flush render
27
+ // pipeline from these so it pins end-to-end behaviour, not just the pure
28
+ // decision function.
29
+ import {
30
+ repairEscapedWhitespace,
31
+ normalizeParagraphBreaks,
32
+ normalizePunctuation,
33
+ stripExcessBold,
34
+ addParagraphSpacers,
35
+ splitMarkdownChunks,
36
+ PARAGRAPH_SPACER,
37
+ RICH_MESSAGE_MAX_CHARS,
38
+ } from '../format.js'
24
39
 
25
40
  describe('isCompositeSilentNoise — Stop-hook re-prompt leak backstop', () => {
26
41
  it('suppresses the observed leak "Sent.\\nNO_REPLY\\nNO_REPLY"', () => {
@@ -109,6 +124,209 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
109
124
  })
110
125
  })
111
126
 
127
+ // ---------------------------------------------------------------------------
128
+ // #2798 — turn-flush must render multiple WHOLE assistant text blocks with
129
+ // paragraph separation, not a collapsed wall-of-text. This suite reconstructs
130
+ // the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
131
+ // decideTurnFlush -> join('\n\n')
132
+ // -> repairEscapedWhitespace -> normalizeParagraphBreaks
133
+ // -> addParagraphSpacers -> splitMarkdownChunks -> sendRichMessage
134
+ // so it pins the end-to-end fix, not just the pure decision. The corpus is a
135
+ // REAL captured-transcript shape (three separate content[i].text blocks, one
136
+ // stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
137
+ // them), NOT a hand-authored ['para A','para B'] that would pass by
138
+ // construction regardless of the join separator.
139
+ // ---------------------------------------------------------------------------
140
+ describe('#2798 turn-flush block separation — real multi-block transcript shape', () => {
141
+ const realBlocks = [
142
+ "I've finished reviewing the three files you flagged.",
143
+ // Untrimmed: a real assistant text block keeps its trailing newline
144
+ // (session-tail.ts drops only empty/whitespace-only blocks). This is the
145
+ // "join('\\n\\n') on a block already ending in '\\n' stacks 3 newlines"
146
+ // case the design note calls out.
147
+ 'The `auth` handler looks correct. The token-refresh path has a race, ' +
148
+ 'though: two concurrent requests can both trigger a refresh and the ' +
149
+ 'second clobbers the first.\n',
150
+ 'Want me to open a PR with the mutex fix, or would you rather patch it ' +
151
+ 'inline first?',
152
+ ]
153
+
154
+ // Mirror the gateway turn-flush send pipeline exactly.
155
+ function renderLikeTurnFlush(blocks: string[]): string {
156
+ const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
157
+ expect(d.kind).toBe('flush')
158
+ const joined = (d as { kind: 'flush'; text: string }).text
159
+ const normalized = normalizeParagraphBreaks(repairEscapedWhitespace(joined))
160
+ return addParagraphSpacers(normalized)
161
+ }
162
+
163
+ it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
164
+ const out = renderLikeTurnFlush(realBlocks)
165
+ // Every block's content survives.
166
+ expect(out).toContain("I've finished reviewing")
167
+ expect(out).toContain('The `auth` handler looks correct')
168
+ expect(out).toContain('Want me to open a PR')
169
+ // The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
170
+ // boundary carries a real paragraph break with the injected visible spacer
171
+ // line (#2692 rich-path spacer), and NOT a single-newline join.
172
+ expect(out).toContain(`\n\n${PARAGRAPH_SPACER}\n\n`)
173
+ expect(out).not.toContain('flagged.\nThe `auth`')
174
+ // A spacer sits specifically between block 1 and block 2.
175
+ const b1 = out.indexOf('flagged.')
176
+ const b2 = out.indexOf('The `auth` handler')
177
+ expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
178
+ })
179
+
180
+ it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
181
+ const out = renderLikeTurnFlush(realBlocks)
182
+ // Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
183
+ // collapses 3+ runs to '\n\n' and addParagraphSpacers wedges exactly one
184
+ // spacer, so no doubled/stacked blank run survives.
185
+ expect(out).not.toMatch(/\n{3,}/)
186
+ // One spacer per block transition: 3 blocks → 2 gaps → 2 spacers.
187
+ const spacerCount = out.split(PARAGRAPH_SPACER).length - 1
188
+ expect(spacerCount).toBe(2)
189
+ })
190
+
191
+ it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
192
+ const out = renderLikeTurnFlush(realBlocks)
193
+ const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
194
+ expect(chunks.length).toBe(1)
195
+ expect(chunks[0]).toContain(PARAGRAPH_SPACER)
196
+ })
197
+
198
+ it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
199
+ // Same real prose shape, but the model closed with NO_REPLY as its final
200
+ // block (intentional silence). The '\n\n' join must NOT defeat the
201
+ // trailing-marker guard.
202
+ const d = decideTurnFlush({
203
+ chatId: '12345',
204
+ replyCalled: false,
205
+ capturedText: [
206
+ 'Reviewed the overnight digest. Nothing needs your attention: all ' +
207
+ 'three checks are green and the backup completed at 04:12.',
208
+ 'NO_REPLY',
209
+ ],
210
+ })
211
+ expect(d).toEqual({ kind: 'skip', reason: 'silent-marker' })
212
+ })
213
+
214
+ it('still SUPPRESSES the composite silent-noise blob under the new join', () => {
215
+ const d = decideTurnFlush({
216
+ chatId: '12345',
217
+ replyCalled: false,
218
+ capturedText: ['Sent.', 'NO_REPLY', 'NO_REPLY'],
219
+ })
220
+ expect(d).toEqual({ kind: 'skip', reason: 'silent-marker' })
221
+ })
222
+ })
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // #2798 reply-parity follow-up — the turn-flush normalization chain must apply
226
+ // the SAME steps in the SAME order as executeReply, so a backstop-delivered
227
+ // answer renders byte-for-byte like the identical text sent via `reply`. Reply
228
+ // runs (gateway executeReply):
229
+ // repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
230
+ // -> stripExcessBold(normalizePunctuation) -> scrubVoice
231
+ // -> addParagraphSpacers (send side)
232
+ // The original #2798 change gave turn-flush the paragraph steps + redact +
233
+ // scrub + spacers but OMITTED `stripExcessBold(normalizePunctuation(...))`.
234
+ // This suite reconstructs the deterministic format chain of BOTH paths (the
235
+ // runtime-only redact + voice-scrub steps are literally the same calls on both
236
+ // paths and are out of scope here) and pins that turn-flush now matches reply
237
+ // on an input that exercises the two added steps: unicode bullets + a spaced
238
+ // en-/em-dash (normalizePunctuation) and an over-bolded prose block
239
+ // (stripExcessBold).
240
+ // ---------------------------------------------------------------------------
241
+ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
242
+ // SOURCE-STRUCTURAL guard (house pattern: gateway-outbound-redact.test.ts
243
+ // reads gateway.ts source and asserts the relative position of calls).
244
+ //
245
+ // The reply-parity contract is that the turn_flush backstop applies the SAME
246
+ // `stripExcessBold(normalizePunctuation(...))` normalization the reply path
247
+ // applies, in the SAME slot — after redactOutboundText, before scrubVoice.
248
+ //
249
+ // A prior version of this suite "guarded" that contract by defining two
250
+ // BYTE-IDENTICAL local functions (replyFormatChain / turnFlushFormatChain)
251
+ // and asserting `toBe` between them. That was tautological: the two locals
252
+ // were equal by construction regardless of what gateway.ts did, so deleting
253
+ // the normalization line from the turn_flush branch reddened NO test. The
254
+ // assertions below instead read the gateway source and pin the actual call
255
+ // ordering, so removing the `stripExcessBold(normalizePunctuation(capturedText))`
256
+ // line from the turn_flush branch REDS this suite — which is the exact
257
+ // regression #2798/#2813 shipped to prevent.
258
+ const gatewaySrc = readFileSync(
259
+ new URL('../gateway/gateway.ts', import.meta.url),
260
+ 'utf8',
261
+ )
262
+
263
+ it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
264
+ const start = gatewaySrc.indexOf('async function executeReply(')
265
+ const redactIdx = gatewaySrc.indexOf(`redactOutboundText(text, 'reply')`, start)
266
+ const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
267
+ const scrubIdx = gatewaySrc.indexOf('scrubVoice(text)', start)
268
+ expect(start).toBeGreaterThan(0)
269
+ expect(redactIdx).toBeGreaterThan(start)
270
+ expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the reply redact
271
+ expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub
272
+ })
273
+
274
+ it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
275
+ const redactIdx = gatewaySrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
276
+ // The normalization call the reply path uses, verbatim, on the turn_flush
277
+ // variable. This indexOf is what returns -1 (→ assertion fails) if the
278
+ // `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
279
+ // from the turn_flush branch.
280
+ const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
281
+ const scrubIdx = gatewaySrc.indexOf('scrubVoice(capturedText)', redactIdx)
282
+ expect(redactIdx).toBeGreaterThan(0)
283
+ expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
284
+ expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
285
+ })
286
+
287
+ it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
288
+ // Parity, structurally: the exact normalization wrapper the reply path uses
289
+ // is the one the turn_flush branch uses — same call, not a lookalike.
290
+ expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(text))')
291
+ expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
292
+ })
293
+
294
+ // Behavioural coverage (kept from the original suite): reconstruct the
295
+ // deterministic format chain both sites apply and assert the REAL
296
+ // strip/normalize behaviour the added step provides — the over-bold block is
297
+ // stripped, unicode bullets become GFM list items, dashes are normalized.
298
+ function formatChain(text: string): string {
299
+ let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
300
+ t = stripExcessBold(normalizePunctuation(t))
301
+ return addParagraphSpacers(t)
302
+ }
303
+
304
+ const input =
305
+ '**This whole paragraph is bolded and is deliberately long enough to ' +
306
+ 'exceed the hundred non-code character floor so the over-bold tripwire ' +
307
+ 'fires on it every time.**\n\n' +
308
+ '• first bullet\n• second bullet\n\n' +
309
+ 'A range like 2019 – 2024 and a spaced em-dash a — b for good measure.'
310
+
311
+ it('strips the over-bold block and normalizes bullets/dashes exactly as reply does', () => {
312
+ const out = formatChain(input)
313
+ // stripExcessBold removed the ** markers from the over-bolded paragraph
314
+ // (the step turn-flush was missing) — the text survives, the markers do not.
315
+ expect(out).not.toContain('**This whole paragraph')
316
+ expect(out).toContain('This whole paragraph is bolded')
317
+ // normalizePunctuation turned unicode bullets into GFM '- ' list items.
318
+ expect(out).toContain('- first bullet')
319
+ expect(out).toContain('- second bullet')
320
+ expect(out).not.toContain('• first bullet')
321
+ // and normalized the dashes: spaced en-dash numeric range -> hyphen,
322
+ // spaced em-dash between words -> comma. Same treatment reply applies.
323
+ expect(out).toContain('2019-2024')
324
+ expect(out).toContain('a, b')
325
+ expect(out).not.toContain('2019 – 2024')
326
+ expect(out).not.toContain('a — b')
327
+ })
328
+ })
329
+
112
330
  describe('decideTurnFlush', () => {
113
331
  it('(a) does NOT flush when the reply tool was called', () => {
114
332
  const decision = decideTurnFlush({
@@ -125,9 +343,12 @@ describe('decideTurnFlush', () => {
125
343
  replyCalled: false,
126
344
  capturedText: ['here is the answer', 'more detail'],
127
345
  })
346
+ // #2798 — whole authored text blocks are joined with a PARAGRAPH break
347
+ // ('\n\n'), not a lone '\n', so adjacent blocks don't collapse into a
348
+ // single run on the rich-markdown path.
128
349
  expect(decision).toEqual({
129
350
  kind: 'flush',
130
- text: 'here is the answer\nmore detail',
351
+ text: 'here is the answer\n\nmore detail',
131
352
  })
132
353
  })
133
354
 
@@ -54,6 +54,52 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
54
54
  expect(approveBlock).not.toMatch(/ask the agent to re-issue the request card/)
55
55
  })
56
56
 
57
+ it('passphrase prompt goes out as a NEW rich message, not an in-place edit', () => {
58
+ // fails when: the cache-miss branch reverts to overloading the
59
+ // ORIGINAL card as the prompt via editMessageText. An in-place
60
+ // edit fires no notification and stays stapled to the card's old
61
+ // position, so a busy topic buries it and the operator never sees
62
+ // the passphrase ask (the reported v0.16.45 admin-key miss). The
63
+ // prompt must be a fresh `sendRichMessage` below.
64
+ const approveBlock =
65
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
66
+ // A distinct passphrase-prompt send exists (verb-tagged).
67
+ expect(approveBlock).toMatch(/sendRichMessage\(pending\.chat_id, richMessage\(promptText\)/)
68
+ expect(approveBlock).toMatch(/vault_request_access\.passphrase_prompt/)
69
+ })
70
+
71
+ it('passphrase prompt renders via richMessage — no raw literal-markdown edit', () => {
72
+ // ROOT CAUSE of the reported bug: the old admin-only and
73
+ // joining-batch branches passed RAW markdown strings (with literal
74
+ // `**`/`_`) straight to editMessageText (parse_mode=none), so the
75
+ // bold/italic rendered as literal characters, and the "locked"
76
+ // branch concatenated a string with a richMessage() object
77
+ // (→ "[object Object]"). Every branch must now flow through the
78
+ // richMessage() GFM render path.
79
+ const approveBlock =
80
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
81
+ // The prompt text is assembled once and wrapped in richMessage.
82
+ expect(approveBlock).toMatch(/const promptText =/)
83
+ // Regression guard: the old raw-string admin-only edit copy is gone.
84
+ expect(approveBlock).not.toMatch(/requires your vault passphrase to grant/)
85
+ // Regression guard: no string-concatenated richMessage() object
86
+ // (the "[object Object]" bug) remains.
87
+ expect(approveBlock).not.toMatch(/\+\s*\n\s*richMessage\(/)
88
+ })
89
+
90
+ it('passphrase prompt is attention-grabbing and does NOT suppress notifications', () => {
91
+ // fails when: the prompt loses its strong header or someone adds
92
+ // disable_notification to it. The whole point of the fix is that
93
+ // the operator gets PINGED — a silent prompt is the bug.
94
+ const approveBlock =
95
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
96
+ expect(approveBlock).toMatch(/ACTION NEEDED: passphrase required/)
97
+ // The send options for the prompt must not carry disable_notification.
98
+ const promptSend =
99
+ approveBlock.split('const promptText =')[1]?.split('return')[0] ?? ''
100
+ expect(promptSend).not.toMatch(/disable_notification/)
101
+ })
102
+
57
103
  it('passphrase intercept deletes the chat message and resumes mint', () => {
58
104
  // fails when: the new pending-op handler stops calling
59
105
  // deleteSensitiveMessage on the passphrase message OR stops
@@ -637,7 +637,7 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
637
637
  // ─── Worker heartbeat (option a — suffix-only, never opens a new message) ─────
638
638
 
639
639
  describe('createWorkerActivityFeed — heartbeat', () => {
640
- it('(i) a tick fires a re-render with a climbing · Ns suffix on a stale worker', async () => {
640
+ it('(i) a tick fires a re-render with a climbing · Ns suffix showing the current step\'s OWN elapsed', async () => {
641
641
  const bot = makeFakeBot()
642
642
  let clock = 10_000
643
643
  const feed = createWorkerActivityFeed({
@@ -649,22 +649,50 @@ describe('createWorkerActivityFeed — heartbeat', () => {
649
649
  setInterval: () => 1,
650
650
  clearInterval: () => {},
651
651
  })
652
- // First paint at elapsed 0 (firstPaintMin default 8000 — use 9000).
652
+ // First paint at elapsed 0 (firstPaintMin default 8000 — use 9000). The
653
+ // narrative line 'pulling data' lands here, so the current step starts now.
653
654
  clock = 19_000
654
655
  await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
655
656
  expect(bot.sent).toHaveLength(1)
656
- const dispatchAt = clock - 9000
657
+ const stepStart = clock // step began when the '→' line first appeared
657
658
 
658
- // Advance past the staleness window so the heartbeat ticks.
659
- clock = 26_000 // lastEditAt(19000) + 7000 ≥ heartbeatTickMs(6000) and ≥ minEditInterval
659
+ // Advance well past the per-step 10s gate so the heartbeat renders a suffix.
660
+ clock = 32_000 // step is now 13s old; lastEditAt(19000)+13000 ≥ heartbeatTickMs
660
661
  feed.heartbeatTick()
661
- await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
662
+ await feed.update('w1', 'chat', view({ elapsedMs: 22_000, latestSummary: 'pulling data' })).catch(() => {})
662
663
  // Drain the chain.
663
- await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
664
+ await feed.update('w1', 'chat', view({ elapsedMs: 22_000, latestSummary: 'pulling data' }))
664
665
  const edit1 = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
665
666
  expect(edit1).toBeDefined()
666
- // The suffix reflects the LIVE elapsed (now - dispatchAt), not the stale view.
667
- expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
667
+ // The step suffix reflects the STEP's OWN elapsed (now - stepStart), NOT
668
+ // the worker total the header already carries the total.
669
+ expect(edit1!.text).toContain(`· ${Math.floor((32_000 - stepStart) / 1000)}s`)
670
+ })
671
+
672
+ it('(i-a) suppresses the step suffix while the current step is younger than the 10s gate', async () => {
673
+ const bot = makeFakeBot()
674
+ let clock = 10_000
675
+ const feed = createWorkerActivityFeed({
676
+ bot,
677
+ now: () => clock,
678
+ minEditIntervalMs: 2500,
679
+ heartbeatTickMs: 6000,
680
+ setInterval: () => 1,
681
+ clearInterval: () => {},
682
+ })
683
+ // Step begins here.
684
+ clock = 19_000
685
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
686
+ expect(bot.sent).toHaveLength(1)
687
+
688
+ // Heartbeat only 7s into the step — under STEP_TIMER_MIN_MS (10s), so no
689
+ // `· Ns` suffix on the '→' line yet (the header total still climbs).
690
+ clock = 26_000
691
+ feed.heartbeatTick()
692
+ await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
693
+ await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
694
+ const stepSuffixEdit = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
695
+ expect(stepSuffixEdit).toBeUndefined()
668
696
  })
669
697
 
670
698
  it('(i-b) heartbeat repaint keeps the header master elapsed >= the step timer (same clock anchor)', async () => {
@@ -801,6 +829,120 @@ describe('createWorkerActivityFeed — heartbeat', () => {
801
829
  await feed.update('w1', 'chat', view({ elapsedMs: 2000, latestSummary: 'go' })).catch(() => {})
802
830
  expect(bot.edits.length).toBe(editsBefore)
803
831
  })
832
+
833
+ // ─── Prose-silent worker: first paint driven by the heartbeat ──────────────
834
+ // The user-visible bug: a background worker that dives straight into quiet
835
+ // work (a long `Bash` / `npm test`) fires ONE `sub_agent_tool_use` tick when
836
+ // the command starts, then no more JSONL lines for the whole run. If that one
837
+ // tick lands before `firstPaintMin` the paint is held — and pre-fix the
838
+ // heartbeat skipped `messageId == null` handles, so nothing ever re-drove the
839
+ // paint and the worker showed NOTHING for its entire run. The heartbeat now
840
+ // performs the first paint once the held handle is past `firstPaintMin`.
841
+ it('(vi) paints a prose-silent worker whose only tick arrived before firstPaintMin, via a later heartbeat', async () => {
842
+ const bot = makeFakeBot()
843
+ let clock = 0
844
+ const feed = createWorkerActivityFeed({
845
+ bot,
846
+ now: () => clock,
847
+ firstPaintMinMs: 8000,
848
+ heartbeatTickMs: 6000,
849
+ minEditIntervalMs: 2500,
850
+ setInterval: () => 1,
851
+ clearInterval: () => {},
852
+ })
853
+ const drain = () => new Promise((r) => setTimeout(r, 0))
854
+
855
+ // A single tool tick at 2s (the Bash invocation) — before firstPaintMin,
856
+ // so the paint is held. This is the ONLY update the worker ever sends.
857
+ clock = 2000
858
+ await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 1, latestSummary: '' }))
859
+ expect(bot.sent).toHaveLength(0)
860
+ expect(feed.messageIdOf('w1')).toBeNull()
861
+
862
+ // A heartbeat still before firstPaintMin holds — trivial workers stay silent.
863
+ clock = 6000
864
+ feed.heartbeatTick()
865
+ await drain()
866
+ expect(bot.sent).toHaveLength(0)
867
+ expect(feed.messageIdOf('w1')).toBeNull()
868
+
869
+ // A heartbeat PAST firstPaintMin performs the first paint — the worker
870
+ // becomes visible even though it never emitted prose or a second tick.
871
+ clock = 12_000
872
+ feed.heartbeatTick()
873
+ await drain()
874
+ expect(bot.sent).toHaveLength(1)
875
+ expect(feed.messageIdOf('w1')).toBe(1000)
876
+ expect(bot.sent[0].text).toContain('🛠 **Worker**')
877
+
878
+ // And it keeps updating: a later heartbeat edits the message with a
879
+ // climbing `· Ns` suffix so the still-alive worker visibly advances.
880
+ clock = 20_000
881
+ feed.heartbeatTick()
882
+ await drain()
883
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
884
+ // No narrative step (prose-silent) → the advance shows in the header's
885
+ // climbing master elapsed rather than a `· Ns` step suffix.
886
+ expect(bot.edits[bot.edits.length - 1].text).toMatch(/_\d+s · 1 tool_/)
887
+ })
888
+
889
+ it('(vii) still holds a trivial sub-firstPaintMin worker silent (heartbeat never force-paints early)', async () => {
890
+ const bot = makeFakeBot()
891
+ let clock = 0
892
+ const feed = createWorkerActivityFeed({
893
+ bot,
894
+ now: () => clock,
895
+ firstPaintMinMs: 8000,
896
+ heartbeatTickMs: 6000,
897
+ setInterval: () => 1,
898
+ clearInterval: () => {},
899
+ })
900
+ const drain = () => new Promise((r) => setTimeout(r, 0))
901
+ // Worker ticks once at 1s then finishes at 3s (handback covers it). No
902
+ // heartbeat before firstPaintMin may paint it.
903
+ clock = 1000
904
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1, latestSummary: '' }))
905
+ clock = 3000
906
+ feed.heartbeatTick()
907
+ await drain()
908
+ expect(bot.sent).toHaveLength(0)
909
+ await feed.finish('w1', view({ state: 'done', toolCount: 1 }))
910
+ // finish with no posted message → no recap edit (handback covers the result).
911
+ expect(bot.edits).toHaveLength(0)
912
+ })
913
+
914
+ it('(viii) a finished, still-unpainted worker is NOT orphan-painted by a later heartbeat', async () => {
915
+ // Regression guard: the heartbeat first-paint branch must never send a
916
+ // fresh `running` message on an already-finished handle. finish() deletes
917
+ // the handle; a subsequent heartbeat (even one past firstPaintMin) must
918
+ // skip it — otherwise a permanently orphaned card that never finalizes.
919
+ const bot = makeFakeBot()
920
+ let clock = 0
921
+ const feed = createWorkerActivityFeed({
922
+ bot,
923
+ now: () => clock,
924
+ firstPaintMinMs: 8000,
925
+ heartbeatTickMs: 6000,
926
+ setInterval: () => 1,
927
+ clearInterval: () => {},
928
+ })
929
+ const drain = () => new Promise((r) => setTimeout(r, 0))
930
+ // One held tick before firstPaintMin — no paint yet.
931
+ clock = 2000
932
+ await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 1, latestSummary: '' }))
933
+ expect(bot.sent).toHaveLength(0)
934
+ expect(feed.messageIdOf('w1')).toBeNull()
935
+ // Worker finishes while still unpainted (finish drops the handle).
936
+ await feed.finish('w1', view({ state: 'done', toolCount: 1 }))
937
+ expect(feed.messageIdOf('w1')).toBeNull()
938
+ // A heartbeat well past firstPaintMin must NOT paint a new message.
939
+ clock = 20_000
940
+ feed.heartbeatTick()
941
+ await drain()
942
+ expect(bot.sent).toHaveLength(0)
943
+ expect(bot.edits).toHaveLength(0)
944
+ expect(feed.messageIdOf('w1')).toBeNull()
945
+ })
804
946
  })
805
947
 
806
948
  // ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
@@ -998,3 +1140,54 @@ describe('header row + rolling overflow survive in the unified worker render', (
998
1140
  expect(isValidWorkerMarkdown(out)).toBe(true)
999
1141
  })
1000
1142
  })
1143
+
1144
+ // ─── Dedup — non-adjacent repeats within the rolling window (unified cards) ──
1145
+
1146
+ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
1147
+ it('a line already in the rolling window is not re-appended', async () => {
1148
+ const bot = makeFakeBot()
1149
+ let clock = 10_000
1150
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1151
+
1152
+ // The live-observed duplication: a preamble ("Look for nested subagents
1153
+ // structure") surfaces, another step lands, then the same text re-fires
1154
+ // (e.g. as the Task tool's describeToolUse label for the same step).
1155
+ const seq = [
1156
+ 'Look for nested subagents structure',
1157
+ 'Inspect subagents dir files',
1158
+ 'Look for nested subagents structure', // non-adjacent repeat — must collapse
1159
+ 'Inspect subagents dir files', // non-adjacent repeat — must collapse
1160
+ 'Tail the child jsonl',
1161
+ ]
1162
+ for (const line of seq) {
1163
+ clock += 1000
1164
+ await feed.update('w-dedup', 'chat', view({ latestSummary: line }))
1165
+ }
1166
+
1167
+ const last = bot.edits.at(-1) ?? bot.sent.at(-1)!
1168
+ const count = (needle: string): number => last.text.split(needle).length - 1
1169
+ expect(count('Look for nested subagents structure')).toBe(1)
1170
+ expect(count('Inspect subagents dir files')).toBe(1)
1171
+ expect(last.text).toContain('Tail the child jsonl')
1172
+ // Source order preserved: first occurrence wins its slot.
1173
+ expect(last.text.indexOf('Look for nested subagents structure'))
1174
+ .toBeLessThan(last.text.indexOf('Inspect subagents dir files'))
1175
+ })
1176
+
1177
+ it('a legitimate revisit re-appears once the earlier copy scrolls out of the window', async () => {
1178
+ const bot = makeFakeBot()
1179
+ let clock = 10_000
1180
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1181
+
1182
+ await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
1183
+ // Push STATUS_ROLLING_LINES distinct lines so 'step-repeat' scrolls out.
1184
+ for (let i = 0; i < STATUS_ROLLING_LINES; i++) {
1185
+ clock += 1000
1186
+ await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
1187
+ }
1188
+ clock += 1000
1189
+ await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
1190
+ const last = bot.edits.at(-1)!
1191
+ expect(last.text).toContain('step-repeat')
1192
+ })
1193
+ })
@@ -16,6 +16,7 @@ function makeSub(over: Partial<Subagent>): Subagent {
16
16
  status: 'running',
17
17
  result_summary: null,
18
18
  jsonl_agent_id: 'a37ad7639ae61476c',
19
+ parent_agent_id: null,
19
20
  ...over,
20
21
  }
21
22
  }
@@ -138,3 +139,27 @@ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
138
139
  }
139
140
  })
140
141
  })
142
+
143
+ describe('resolveWorkerFeedDispatch — nested/row-presence signals (unified progress cards)', () => {
144
+ it('hasRow=false for a missing registry row (never silently foreground-nest it)', () => {
145
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent')
146
+ expect(out.hasRow).toBe(false)
147
+ expect(out.isNested).toBe(false)
148
+ })
149
+
150
+ it('hasRow=true for any present row', () => {
151
+ expect(resolveWorkerFeedDispatch(makeSub({}), 'sub-agent').hasRow).toBe(true)
152
+ })
153
+
154
+ it('isNested=true when parent_agent_id is set (depth-2+ dispatch)', () => {
155
+ const sub = makeSub({ parent_agent_id: 'ac15e1e3528f421d6', background: false })
156
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
157
+ expect(out.isNested).toBe(true)
158
+ // Its own background flag stays honest — the caller ORs isNested in.
159
+ expect(out.isBackground).toBe(false)
160
+ })
161
+
162
+ it('isNested=false for a main-session dispatch (parent_agent_id null)', () => {
163
+ expect(resolveWorkerFeedDispatch(makeSub({ parent_agent_id: null }), 's').isNested).toBe(false)
164
+ })
165
+ })