switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Outcome pins for the on-demand voice ('🔊 Listen') callback family
3
+ * (`voice:<token>`) extracted from the gateway's callback_query dispatcher
4
+ * (switchroom#2996 P6). Uses the REAL voice-ondemand token helpers and the
5
+ * REAL sendVoiceReusingFileId contract via injected send fns: asserts the
6
+ * fall-through for non-voice data, the auth gate, expiry toast, the
7
+ * file_id fast path (no upload), the upload path capturing the returned
8
+ * file_id, and the single-use keyboard strip on success only.
9
+ */
10
+
11
+ import { describe, it, expect, vi } from 'vitest'
12
+ import {
13
+ handleVoiceOnDemandCallback,
14
+ type VoiceOnDemandCallbackDeps,
15
+ } from '../gateway/voice-ondemand-callback-handler.js'
16
+
17
+ const TOKEN = 'tok123'
18
+ const DATA = `voice:${TOKEN}`
19
+
20
+ function makeCtx(over: Record<string, unknown> = {}) {
21
+ return {
22
+ from: { id: 42 },
23
+ chat: { id: 42 },
24
+ callbackQuery: { message: { message_id: 77 } },
25
+ answerCallbackQuery: vi.fn(async () => {}),
26
+ ...over,
27
+ } as any
28
+ }
29
+
30
+ function makeDeps(over: Partial<VoiceOnDemandCallbackDeps> = {}) {
31
+ const setTelegramFileId = vi.fn()
32
+ const sendVoiceByFileId = vi.fn(async () => ({ voice: { file_id: 'same' } }))
33
+ const sendVoiceByUpload = vi.fn(async () => ({ voice: { file_id: 'fresh-id' } }))
34
+ const stripListenKeyboard = vi.fn(async () => {})
35
+ const log = vi.fn()
36
+ const deps: VoiceOnDemandCallbackDeps = {
37
+ loadAccess: () => ({ allowFrom: ['42'] }),
38
+ voiceOnDemandCache: {
39
+ get: () => null,
40
+ setTelegramFileId,
41
+ },
42
+ sendVoiceByFileId,
43
+ sendVoiceByUpload,
44
+ stripListenKeyboard,
45
+ log,
46
+ ...over,
47
+ }
48
+ return { deps, setTelegramFileId, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard, log }
49
+ }
50
+
51
+ describe('handleVoiceOnDemandCallback — routing + gates', () => {
52
+ it('returns false for non-voice callback data', async () => {
53
+ const { deps } = makeDeps()
54
+ const ctx = makeCtx()
55
+ expect(await handleVoiceOnDemandCallback(ctx, 'agent:whatever', deps)).toBe(false)
56
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it('gates non-allowlisted senders', async () => {
60
+ const { deps, sendVoiceByFileId } = makeDeps()
61
+ const ctx = makeCtx({ from: { id: 666 } })
62
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
63
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
64
+ expect(sendVoiceByFileId).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('acks an expired token politely', async () => {
68
+ const { deps } = makeDeps() // cache.get → null
69
+ const ctx = makeCtx()
70
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
71
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({
72
+ text: 'Voice expired — send again to hear it.',
73
+ })
74
+ })
75
+ })
76
+
77
+ describe('handleVoiceOnDemandCallback — send paths', () => {
78
+ it('file_id fast path: sends BY id, strips the keyboard, no upload', async () => {
79
+ const { deps, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
80
+ voiceOnDemandCache: {
81
+ get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
82
+ setTelegramFileId: vi.fn(),
83
+ },
84
+ })
85
+ const ctx = makeCtx()
86
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
87
+ expect(sendVoiceByFileId).toHaveBeenCalledWith(
88
+ '42',
89
+ 'cached-id',
90
+ expect.objectContaining({ reply_parameters: { message_id: 77 } }),
91
+ undefined,
92
+ )
93
+ expect(sendVoiceByUpload).not.toHaveBeenCalled()
94
+ expect(stripListenKeyboard).toHaveBeenCalledWith('42', 77, undefined)
95
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '🔊' })
96
+ })
97
+
98
+ it('pre-synth file path: uploads the on-disk audio and captures the returned file_id', async () => {
99
+ const { mkdtempSync, writeFileSync } = await import('node:fs')
100
+ const { tmpdir } = await import('node:os')
101
+ const { join } = await import('node:path')
102
+ const dir = mkdtempSync(join(tmpdir(), 'voice-od-'))
103
+ const fp = join(dir, 'a.ogg')
104
+ writeFileSync(fp, Buffer.from([1, 2, 3]))
105
+ const setTelegramFileId = vi.fn()
106
+ const { deps, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
107
+ voiceOnDemandCache: {
108
+ get: () => ({ text: 'hello', speed: 1, filePath: fp }),
109
+ setTelegramFileId,
110
+ },
111
+ })
112
+ const ctx = makeCtx()
113
+ await handleVoiceOnDemandCallback(ctx, DATA, deps)
114
+ expect(sendVoiceByUpload).toHaveBeenCalled()
115
+ // Returned message's file_id captured back into the cache for reuse.
116
+ expect(setTelegramFileId).toHaveBeenCalledWith(TOKEN, 'fresh-id')
117
+ expect(stripListenKeyboard).toHaveBeenCalled()
118
+ })
119
+
120
+ it('send failure: logs non-fatally and leaves the keyboard intact (retryable)', async () => {
121
+ const { deps, stripListenKeyboard, log } = makeDeps({
122
+ voiceOnDemandCache: {
123
+ get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
124
+ setTelegramFileId: vi.fn(),
125
+ },
126
+ sendVoiceByFileId: vi.fn(async () => {
127
+ throw new Error('boom')
128
+ }),
129
+ // Stale-id fallback then tries upload; make it fail too so the send
130
+ // resolves as send-failed overall. filePath absent → lazy synth path
131
+ // would need the sidecar; keep it simple: upload throws.
132
+ sendVoiceByUpload: vi.fn(async () => {
133
+ throw new Error('boom2')
134
+ }),
135
+ })
136
+ const ctx = makeCtx()
137
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
138
+ expect(stripListenKeyboard).not.toHaveBeenCalled()
139
+ })
140
+ })
@@ -6,7 +6,7 @@ import {
6
6
  type WorkerActivityView,
7
7
  type BotApiForWorkerFeed,
8
8
  } from '../worker-activity-feed.js'
9
- import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
9
+ import { STATUS_ROLLING_LINES, WORKER_HISTORY_MAX, STATUS_LINE_MAX } from '../status-no-truncate.js'
10
10
  import { SEND_GATE_SHED } from '../send-gate.js'
11
11
 
12
12
  describe('isWorkerActivityFeedEnabled (default ON)', () => {
@@ -188,17 +188,31 @@ describe('renderWorkerActivity', () => {
188
188
  expect(stepCount(out)).toBe(2)
189
189
  })
190
190
 
191
- it('shows a "+N earlier…" header when the feed exceeds STATUS_ROLLING_LINES (worker surface)', () => {
192
- const total = STATUS_ROLLING_LINES + 3
191
+ it('shows a "+N earlier…" header when the feed exceeds WORKER_HISTORY_MAX (worker surface, #3349)', () => {
192
+ const total = WORKER_HISTORY_MAX + 3
193
193
  const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
194
194
  const out = renderWorkerActivity(view({ narrativeLines: lines }))
195
- expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
195
+ expect(out).toContain(`_✓ +${total - WORKER_HISTORY_MAX} earlier…_`)
196
196
  expect(out).not.toContain('step 1<')
197
- const firstVisible = total - STATUS_ROLLING_LINES + 1
197
+ const firstVisible = total - WORKER_HISTORY_MAX + 1
198
198
  expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
199
199
  expect(out).toContain(`**→ step ${total}**`)
200
- // STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
201
- expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
200
+ // WORKER_HISTORY_MAX visible step lines (the overflow header isn't a step).
201
+ expect(out.match(/step \d/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
202
+ })
203
+
204
+ it('the lone-worker card shows the full 6-step trail — the raised ceiling (#3349)', () => {
205
+ // Ken's curve at w=1 is 6; the single-worker surface must reach it (the old
206
+ // STATUS_ROLLING_LINES=5 cap could never show the 6th step).
207
+ expect(WORKER_HISTORY_MAX).toBe(6)
208
+ const lines = Array.from({ length: 8 }, (_, n) => `ln-${n + 1}`)
209
+ const out = renderWorkerActivity(view({ narrativeLines: lines }))
210
+ // Last 6 render (ln-3 … ln-8); the two oldest collapse into a "+2 earlier…".
211
+ for (let n = 3; n <= 8; n++) expect(out).toContain(`ln-${n}`)
212
+ expect(out).toContain('_✓ +2 earlier…_')
213
+ expect(out).toContain('**→ ln-8**') // newest last, bold in-progress
214
+ // Exactly 6 rendered step bullets (the +N header is not a bullet).
215
+ expect((out.match(/ln-\d/g) ?? []).length).toBe(6)
202
216
  })
203
217
 
204
218
  it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
@@ -457,7 +471,7 @@ describe('createWorkerActivityFeed', () => {
457
471
  expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
458
472
  })
459
473
 
460
- it('rolls the narrative block to the last STATUS_ROLLING_LINES lines', async () => {
474
+ it('rolls the narrative block to the last WORKER_HISTORY_MAX lines (#3349)', async () => {
461
475
  const bot = makeFakeBot()
462
476
  let clock = 10_000
463
477
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
@@ -469,12 +483,34 @@ describe('createWorkerActivityFeed', () => {
469
483
  }
470
484
 
471
485
  const last = bot.edits.at(-1)!
472
- expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
473
- const firstVisible = total - STATUS_ROLLING_LINES + 1
486
+ expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
487
+ const firstVisible = total - WORKER_HISTORY_MAX + 1
474
488
  for (let i = 1; i < firstVisible; i++) expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
475
489
  for (let i = firstVisible; i <= total; i++) expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
476
490
  })
477
491
 
492
+ it('the deeper 6-step trail does NOT add edits — one landed render per substantive step (#3349, no #3270 flood)', async () => {
493
+ // Render-only change: edit frequency is a function of SUBSTANTIVE changes
494
+ // (new narrative step), never of how many trail lines a render carries. Push
495
+ // 8 distinct steps to a lone worker; with the edit floor at 0 exactly one
496
+ // render lands per step: 1 send + 7 edits = 8 landed renders, regardless of
497
+ // the 6-line trail depth. A widened window that re-fired edits would break
498
+ // this (that is the #3270 flood we must not reintroduce).
499
+ const bot = makeFakeBot()
500
+ let clock = 10_000
501
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
502
+ for (let i = 1; i <= 8; i++) {
503
+ clock += 1000
504
+ await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `edit-step-${i}` }))
505
+ }
506
+ expect(bot.sent).toHaveLength(1)
507
+ expect(bot.edits).toHaveLength(7)
508
+ // Re-pushing the SAME newest step (no substance change) fires NO extra edit.
509
+ clock += 1000
510
+ await feed.update('w1', 'chat', view({ toolCount: 8, latestSummary: 'edit-step-8' }))
511
+ expect(bot.edits).toHaveLength(7)
512
+ })
513
+
478
514
  it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
479
515
  const bot = makeFakeBot()
480
516
  let clock = 10_000
@@ -576,10 +612,10 @@ describe('createWorkerActivityFeed — log sink', () => {
576
612
  // char-budget backstop is the only wire-limit ceiling.
577
613
 
578
614
  describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
579
- it('with 12 narrative lines, exactly the last STATUS_ROLLING_LINES render + a +N earlier header', () => {
615
+ it('with 12 narrative lines, exactly the last WORKER_HISTORY_MAX render + a +N earlier header (#3349)', () => {
580
616
  const narrativeLines = Array.from({ length: 12 }, (_, i) => `stp-${String(i + 1).padStart(3, '0')}`)
581
617
  const out = renderWorkerActivity(view({ narrativeLines }))
582
- const firstVisible = 12 - STATUS_ROLLING_LINES + 1
618
+ const firstVisible = 12 - WORKER_HISTORY_MAX + 1
583
619
  for (let i = firstVisible; i <= 12; i++) {
584
620
  expect(out).toContain(`stp-${String(i).padStart(3, '0')}`)
585
621
  }
@@ -587,7 +623,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
587
623
  expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
588
624
  }
589
625
  // Overflow header now appears on the worker surface too.
590
- expect(out).toContain(`_✓ +${12 - STATUS_ROLLING_LINES} earlier…_`)
626
+ expect(out).toContain(`_✓ +${12 - WORKER_HISTORY_MAX} earlier…_`)
591
627
  expect(out).toContain('**→ stp-012**')
592
628
  })
593
629
 
@@ -623,7 +659,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
623
659
  })
624
660
 
625
661
  describe('rolling window — createWorkerActivityFeed narrative accumulation', () => {
626
- it('with 12 pushes, only the last STATUS_ROLLING_LINES appear in the render', async () => {
662
+ it('with 12 pushes, only the last WORKER_HISTORY_MAX appear in the render (#3349)', async () => {
627
663
  const bot = makeFakeBot()
628
664
  let clock = 10_000
629
665
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
@@ -634,16 +670,16 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
634
670
  }
635
671
 
636
672
  const last = bot.edits.at(-1)!
637
- const firstVisible = 12 - STATUS_ROLLING_LINES + 1
673
+ const firstVisible = 12 - WORKER_HISTORY_MAX + 1
638
674
  for (let i = firstVisible; i <= 12; i++) {
639
675
  expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
640
676
  }
641
677
  for (let i = 1; i < firstVisible; i++) {
642
678
  expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
643
679
  }
644
- // The manager caps the in-memory narrative at STATUS_ROLLING_LINES, so the
680
+ // The manager caps the in-memory narrative at WORKER_HISTORY_MAX, so the
645
681
  // render never sees overflow — no "+N earlier…" marker on the manager path
646
- // (it surfaces only on direct renderWorkerActivity calls with >5 lines).
682
+ // (it surfaces only on direct renderWorkerActivity calls with >6 lines).
647
683
  expect(last.text).not.toContain('earlier…')
648
684
  expect(last.text).toContain('**→ ln-012**')
649
685
  })
@@ -1027,6 +1063,37 @@ describe('createWorkerActivityFeed — resurrection guard + deferred finalize',
1027
1063
  expect(bot.sent[1].text).toContain('back to work')
1028
1064
  })
1029
1065
 
1066
+ it('#3373: a GENUINE terminal then a SendMessage resume re-surfaces a live card (resurrect+update), while an un-resumed terminal stays dead', async () => {
1067
+ const bot = makeFakeBot()
1068
+ let clock = 10_000
1069
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1070
+
1071
+ // Worker runs, then GENUINELY completes (a real turn_end handback) — the
1072
+ // row is dropped and the agentId latched into the terminal `finalized` gate.
1073
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
1074
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'real result' }))
1075
+ expect(feed.has('w1')).toBe(false)
1076
+
1077
+ // Anti-zombie: with NO resume signal, later cues stay swallowed forever — a
1078
+ // genuinely-finished worker must never zombie-resurface on a stray tick.
1079
+ clock = 12_000
1080
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'stray tick' }))
1081
+ expect(bot.sent).toHaveLength(1)
1082
+ expect(feed.has('w1')).toBe(false)
1083
+
1084
+ // Now the worker is RESUMED via SendMessage: the watcher observes jsonl
1085
+ // growth past the terminal boundary (#3315) and fires onResume, which the
1086
+ // gateway wires to feed.resurrect — clearing the finalized latch.
1087
+ feed.resurrect('w1')
1088
+
1089
+ // The resumed worker's next progress cue re-surfaces a fresh live card.
1090
+ clock = 13_000
1091
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'resumed work' }))
1092
+ expect(bot.sent).toHaveLength(2)
1093
+ expect(feed.has('w1')).toBe(true)
1094
+ expect(bot.sent[1].text).toContain('resumed work')
1095
+ })
1096
+
1030
1097
  it('#3023: resurrect() on a never-finalized worker is a harmless no-op', () => {
1031
1098
  const bot = makeFakeBot()
1032
1099
  const feed = createWorkerActivityFeed({ bot, now: () => 10_000 })
@@ -1337,8 +1404,8 @@ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
1337
1404
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1338
1405
 
1339
1406
  await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
1340
- // Push STATUS_ROLLING_LINES distinct lines so 'step-repeat' scrolls out.
1341
- for (let i = 0; i < STATUS_ROLLING_LINES; i++) {
1407
+ // Push WORKER_HISTORY_MAX distinct lines so 'step-repeat' scrolls out (#3349).
1408
+ for (let i = 0; i < WORKER_HISTORY_MAX; i++) {
1342
1409
  clock += 1000
1343
1410
  await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
1344
1411
  }
@@ -186,12 +186,20 @@ describe('coalesced worker feed — one message per chat under load', () => {
186
186
  // would shed ~ (N-1)/N of every second's cosmetic edits.
187
187
  expect(gate.stats().global.shed).toBeLessThanOrEqual(3)
188
188
 
189
- // Liveness: the LAST landed edit carries EVERY worker's latest step — all
190
- // rows refreshed together in the one message within one edit cycle.
189
+ // Liveness + bounded card (#3349): under Ken's curve every SHOWN worker
190
+ // keeps depth 3, so a 15-way storm cannot render every row — the 24-line
191
+ // body ceiling collapses the newest rows into a `+N more working…` spill.
192
+ // The last landed edit still carries the VISIBLE workers' latest steps
193
+ // (refreshed together in the one message) and a spill marker for the rest.
191
194
  const lastBody = edits[edits.length - 1].text
192
- for (const id of ids) {
193
- expect(lastBody).toContain(latest[id])
195
+ // 6 rows fit the 24-line budget (6·(1 header + 3 depth) = 24); the oldest
196
+ // rows (w0…w5) stay visible, the 9 newest spill.
197
+ for (let i = 0; i < 6; i++) {
198
+ expect(lastBody).toContain(latest[`w${i}`])
194
199
  }
200
+ expect(lastBody).toContain('more working')
201
+ // Still exactly ONE message — the spill is a render detail, not a new send.
202
+ expect(sends.length).toBe(1)
195
203
  })
196
204
 
197
205
  it('admits a critical reply mid-storm (never shed or starved by the cosmetic feed)', async () => {
@@ -722,21 +730,23 @@ describe('renderCombinedWorkerFeed (pure)', () => {
722
730
  expect(body).toContain('b first')
723
731
  })
724
732
 
725
- it('degrades to ONE history line per worker at a large fan-out and stays within the body budget', () => {
733
+ it('holds the MIN_WORKER_DEPTH (3) floor per shown worker at a large fan-out and stays within the body budget (#3349)', () => {
734
+ // 6 workers each with a deep (5-line) history. Ken's curve pins depth=3 at
735
+ // 4+ workers, so every SHOWN worker paints its last 3 steps. 6·(1 header +
736
+ // 3 history) = 24 lines = MAX_COMBINED_BODY_LINES, so all 6 stay visible.
726
737
  const rows = Array.from({ length: 6 }, (_, i) =>
727
- rowH(i, [`w${i} oldest`, `w${i} middle`, `w${i} newest`]),
738
+ rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`, `w${i} s4`, `w${i} s5`]),
728
739
  )
729
740
  const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
730
- // Only the newest step of each worker survives — the earlier lines are
731
- // dropped by the per-worker depth clamp (floor((13-6)/6)=1).
732
741
  for (let i = 0; i < 6; i++) {
733
- expect(body).toContain(current(`w${i} newest`))
734
- expect(body).not.toContain(`w${i} oldest`)
735
- expect(body).not.toContain(`w${i} middle`)
742
+ // Last 3 steps shown; the two oldest dropped by the depth-3 window.
743
+ expect(body).toContain(current(`w${i} s5`))
744
+ expect(body).toContain(struck(`w${i} s4`))
745
+ expect(body).toContain(struck(`w${i} s3`))
746
+ expect(body).not.toContain(`w${i} s1`)
747
+ expect(body).not.toContain(`w${i} s2`)
736
748
  }
737
- // Total body lines (worker headers + history) stay within the budget: 6
738
- // header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
739
- // only the per-worker body lines (exclude the top count line + any spill).
749
+ // Body lines (worker headers + history) stay within the re-derived budget.
740
750
  const bodyLines = body
741
751
  .split('\n')
742
752
  .map((l) => l.trim())
@@ -744,15 +754,137 @@ describe('renderCombinedWorkerFeed (pure)', () => {
744
754
  const headerAndHistory = bodyLines.filter(
745
755
  (l) => !l.startsWith('🛠') && !l.includes('more working'),
746
756
  )
747
- expect(headerAndHistory.length).toBeLessThanOrEqual(13)
757
+ expect(headerAndHistory.length).toBeLessThanOrEqual(24)
748
758
  })
749
759
 
750
- it('exposes the deterministic depth formula (2→5, 3→3, 4→2, 6→1)', () => {
760
+ it('drops NEWEST (trailing) rows into the spill when the fan-out overflows the 24-line body budget, keeping per-worker depth at 3 (#3349)', () => {
761
+ // 8 workers × (1 header + 3 depth) = 32 > 24 → the backstop collapses the 2
762
+ // newest (trailing) visible rows into `+M more working…`, so 6 rows render at full
763
+ // depth-3 (24 lines) rather than every worker losing a step.
764
+ const rows = Array.from({ length: 8 }, (_, i) =>
765
+ rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`]),
766
+ )
767
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
768
+ expect(body).toContain('more working')
769
+ const bodyLines = body
770
+ .split('\n')
771
+ .map((l) => l.trim())
772
+ .filter((l) => l.length > 0)
773
+ const headerAndHistory = bodyLines.filter(
774
+ (l) => !l.startsWith('🛠') && !l.includes('more working'),
775
+ )
776
+ expect(headerAndHistory.length).toBeLessThanOrEqual(24)
777
+ expect(body).toContain('+2 more working')
778
+ // The 6 kept rows render at full depth-3 (last row kept shows all 3 steps).
779
+ expect(body).toContain(current('w5 s3'))
780
+ expect(body).toContain(struck('w5 s2'))
781
+ expect(body).toContain(struck('w5 s1'))
782
+ // The 2 trailing rows spilled — none of worker 7's lines render.
783
+ expect(body).not.toContain('w7 s3')
784
+ })
785
+
786
+ it("exposes Ken's deterministic depth curve max(3, 7−w): 1→6, 2→5, 3→4, 4→3, 5+→3 (#3349)", () => {
787
+ expect(combinedHistoryDepth(1)).toBe(6)
751
788
  expect(combinedHistoryDepth(2)).toBe(5)
752
- expect(combinedHistoryDepth(3)).toBe(3)
753
- expect(combinedHistoryDepth(4)).toBe(2)
754
- expect(combinedHistoryDepth(6)).toBe(1)
755
- expect(combinedHistoryDepth(8)).toBe(1)
789
+ expect(combinedHistoryDepth(3)).toBe(4)
790
+ expect(combinedHistoryDepth(4)).toBe(3)
791
+ expect(combinedHistoryDepth(5)).toBe(3)
792
+ expect(combinedHistoryDepth(6)).toBe(3)
793
+ expect(combinedHistoryDepth(8)).toBe(3)
794
+ })
795
+
796
+ // ── Worker numbering (#3298): stable ordinal prefix at 2+ workers ──────────
797
+ const rowN = (i: number, ordinal: number) => ({ ...row(i, `step ${i}`), ordinal })
798
+
799
+ it('numbers the workers with a literal "N. " prefix inside the bold header when 2+ run', () => {
800
+ const body = renderCombinedWorkerFeed([rowN(1, 1), rowN(2, 2)], { maxRows: 8 })!
801
+ // The literal `1. ` must survive markdown rendering, inside the bold span
802
+ // before the escaped description.
803
+ expect(body).toContain('**1. task number 1**')
804
+ expect(body).toContain('**2. task number 2**')
805
+ })
806
+
807
+ it('renders non-contiguous ordinals as-is (survivors keep their numbers after a finish)', () => {
808
+ // Worker 1 finished upstream — the surviving rows arrive with their
809
+ // ORIGINAL ordinals. Positional numbering would render 1./2. here; the
810
+ // stable scheme must render 2./3. and never a `1. `.
811
+ const body = renderCombinedWorkerFeed([rowN(2, 2), rowN(3, 3)], { maxRows: 8 })!
812
+ expect(body).toContain('**2. task number 2**')
813
+ expect(body).toContain('**3. task number 3**')
814
+ expect(body).not.toContain('**1. ')
815
+ })
816
+
817
+ it('a single row is never numbered, even when it carries an ordinal', () => {
818
+ const body = renderCombinedWorkerFeed([rowN(1, 1)], { maxRows: 8 })!
819
+ expect(body).toContain('**task number 1**')
820
+ expect(body).not.toContain('**1. ')
821
+ })
822
+
823
+ it('rows without ordinals render unnumbered (back-compat for direct callers)', () => {
824
+ const body = renderCombinedWorkerFeed([row(1, 'alpha'), row(2, 'beta')], { maxRows: 8 })!
825
+ expect(body).toContain('**task number 1**')
826
+ expect(body).toContain('**task number 2**')
827
+ expect(body).not.toContain('1. task')
828
+ })
829
+
830
+ it('spilled rows take their ordinals with them; visible ordinals do not shift', () => {
831
+ const rows = Array.from({ length: 10 }, (_, i) => rowN(i + 1, i + 1))
832
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 4 })!
833
+ expect(body).toContain('+6 more working')
834
+ for (let n = 1; n <= 4; n++) expect(body).toContain(`**${n}. task number ${n}**`)
835
+ expect(body).not.toContain('**5. ')
836
+ })
837
+ })
838
+
839
+ // ── #3349: Ken's per-worker depth curve 6/5/4/3, exact rendered depth ─────────
840
+ describe("Ken's per-worker step-trail depth curve (#3349)", () => {
841
+ // A worker with a DEEP history (8 lines) so the depth curve — not the buffer —
842
+ // is always the binding limit. Each history line is uniquely tokenised
843
+ // (`w{i}-s{n}`) so we can count exactly how many of a worker's steps rendered.
844
+ const deepRow = (i: number) => ({
845
+ description: `task w${i}`,
846
+ elapsedMs: 12_000 + i * 1000,
847
+ toolCount: 3,
848
+ ordinal: i + 1,
849
+ currentStep: `w${i}-s8`,
850
+ historyLines: Array.from({ length: 8 }, (_, n) => `w${i}-s${n + 1}`),
851
+ })
852
+ // Count how many of worker `i`'s step tokens appear in the rendered body.
853
+ const shownSteps = (body: string, i: number): number =>
854
+ (body.match(new RegExp(`w${i}-s\\d`, 'g')) ?? []).length
855
+
856
+ it.each([
857
+ [1, 6],
858
+ [2, 5],
859
+ [3, 4],
860
+ [4, 3],
861
+ [5, 3],
862
+ ])('%i worker(s) → exactly %i steps rendered per worker', (workers, expectedDepth) => {
863
+ const rows = Array.from({ length: workers }, (_, i) => deepRow(i))
864
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
865
+ expect(combinedHistoryDepth(workers)).toBe(expectedDepth)
866
+ for (let i = 0; i < workers; i++) {
867
+ expect(shownSteps(body, i)).toBe(expectedDepth)
868
+ }
869
+ // Newest step last (bottom-anchored → bold), oldest rendered step struck.
870
+ const worker0 = `**→ w0-s8**`
871
+ expect(body).toContain(worker0)
872
+ })
873
+
874
+ it('a worker with FEWER steps than the depth shows all it has, no padding', () => {
875
+ // 3 workers → depth 4, but this worker only has 2 steps: render both, no more.
876
+ const rows = [
877
+ { description: 'shallow', elapsedMs: 12_000, toolCount: 1, ordinal: 1,
878
+ currentStep: 'only-b', historyLines: ['only-a', 'only-b'] },
879
+ deepRow(1),
880
+ deepRow(2),
881
+ ]
882
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
883
+ expect(combinedHistoryDepth(3)).toBe(4)
884
+ expect(body).toContain('~~_✓ only-a_~~')
885
+ expect(body).toContain('**→ only-b**')
886
+ // Deep workers still get their full 4.
887
+ expect(shownSteps(body, 1)).toBe(4)
756
888
  })
757
889
  })
758
890
 
@@ -1019,3 +1151,87 @@ describe('worker-feed ghost-leak — deterministic terminal removal + backstop',
1019
1151
  expect(edits.length).toBe(afterPaint)
1020
1152
  })
1021
1153
  })
1154
+
1155
+ describe('worker numbering — stable per-card ordinals end to end (#3298)', () => {
1156
+ const doneView = (desc: string, elapsedMs: number): WorkerActivityView => ({
1157
+ description: desc,
1158
+ lastTool: null,
1159
+ toolCount: 3,
1160
+ latestSummary: `${desc} result`,
1161
+ elapsedMs,
1162
+ state: 'done',
1163
+ })
1164
+ const lastBody = (h: { sends: { text: string }[]; edits: { messageId: number; text: string }[] }): string => {
1165
+ if (h.edits.length > 0) return h.edits[h.edits.length - 1].text
1166
+ return h.sends[h.sends.length - 1].text
1167
+ }
1168
+
1169
+ it('assigns 1./2./3. at dispatch and survivors KEEP their numbers when an earlier worker finishes', async () => {
1170
+ let clock = 1000
1171
+ const h = ghostHarness({ now: () => clock })
1172
+ await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
1173
+ await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
1174
+ await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
1175
+ await drain()
1176
+
1177
+ let body = lastBody(h)
1178
+ expect(body).toContain('**1. task alpha**')
1179
+ expect(body).toContain('**2. task beta**')
1180
+ expect(body).toContain('**3. task gamma**')
1181
+
1182
+ // Worker 1 finishes: the survivors must STILL be 2. and 3. — positional
1183
+ // numbering would renumber them 1./2. and this assertion would fail.
1184
+ clock = 2000
1185
+ await h.feed.finish('a', doneView('task alpha', 1000))
1186
+ await drain()
1187
+ body = lastBody(h)
1188
+ expect(body).toContain('2 running')
1189
+ expect(body).toContain('**2. task beta**')
1190
+ expect(body).toContain('**3. task gamma**')
1191
+ expect(body).not.toContain('**1. ')
1192
+ })
1193
+
1194
+ it('a single running worker is never numbered (single-worker layout)', async () => {
1195
+ const h = ghostHarness({ now: () => 1000 })
1196
+ await h.feed.update('solo', 'chat', view('task solo', 'working', 0))
1197
+ await drain()
1198
+ const body = lastBody(h)
1199
+ expect(body).toContain('task solo')
1200
+ expect(body).not.toContain('1. task solo')
1201
+ })
1202
+
1203
+ it('a fresh card after ALL workers finish numbers from 1 again', async () => {
1204
+ let clock = 1000
1205
+ const h = ghostHarness({ now: () => clock })
1206
+ await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
1207
+ await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
1208
+ await drain()
1209
+ clock = 2000
1210
+ await h.feed.finish('a', doneView('task alpha', 1000))
1211
+ await h.feed.finish('b', doneView('task beta', 1000))
1212
+ await drain()
1213
+ expect(h.feed.size).toBe(0)
1214
+
1215
+ // New fan-out in the same chat → a NEW card that starts at 1., not 3.
1216
+ clock = 3000
1217
+ await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
1218
+ await h.feed.update('d', 'chat', view('task delta', 'd-doing', 0))
1219
+ await drain()
1220
+ const body = lastBody(h)
1221
+ expect(body).toContain('**1. task gamma**')
1222
+ expect(body).toContain('**2. task delta**')
1223
+ })
1224
+
1225
+ it('introducing ordinals causes no dedup churn (same substance → edit still skipped)', async () => {
1226
+ const h = ghostHarness({ now: () => 1000 })
1227
+ await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
1228
+ await h.feed.update('b', 'chat', view('task beta', 'same step', 0))
1229
+ await drain()
1230
+ const landed = h.edits.length
1231
+ // Byte-identical substance re-update: the dedup/no-op skip must still fire
1232
+ // (a stable ordinal never varies the substance between identical renders).
1233
+ await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
1234
+ await drain()
1235
+ expect(h.edits.length).toBe(landed)
1236
+ })
1237
+ })