switchroom 0.19.25 → 0.19.27

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 (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,362 @@
1
+ /**
2
+ * END-TO-END card lifecycle — drives the REAL feed manager
3
+ * (`createWorkerActivityFeed`) with realistic worker cues and asserts the
4
+ * EXACT text that reaches Telegram at every stage of a worker's life:
5
+ *
6
+ * dispatch → running → a second worker joins (coalesce) → spill/overflow
7
+ * → one finishes (survivors keep ordinals) → failure recap → supersede.
8
+ *
9
+ * This is deliberately NOT `renderStatusCard(opts)` in isolation. The seam is
10
+ * the highest one drivable in-process: feed state in, `sendMessage` /
11
+ * `editMessageText` bodies out — the same bytes the user reads on their phone.
12
+ * The golden suite (`card-golden.test.ts`) pins each variant's shape; this
13
+ * suite proves the lifecycle actually PRODUCES those variants, in order.
14
+ *
15
+ * Every assertion here is one that fails if the shared card layout core
16
+ * (`card-layout.ts`) changes header composition, step-block drawing, the
17
+ * rolling window, the placeholder, the spill line or the budget backstop.
18
+ */
19
+ import { describe, expect, it } from 'vitest'
20
+
21
+ import {
22
+ createWorkerActivityFeed,
23
+ WORKER_CARD_SUPERSEDED_BODY,
24
+ type BotApiForWorkerFeed,
25
+ type WorkerActivityView,
26
+ } from '../worker-activity-feed.js'
27
+ import { WORKER_STEP_INDENT } from '../status-no-truncate.js'
28
+
29
+ const CHAT = '4242'
30
+
31
+ interface Painted {
32
+ kind: 'send' | 'edit'
33
+ messageId: number
34
+ text: string
35
+ }
36
+
37
+ function harness(opts: { maxRows?: number; groupMessageLifetimeCapMs?: number } = {}) {
38
+ let now = 1_000_000
39
+ const painted: Painted[] = []
40
+ let nextId = 500
41
+ const bot: BotApiForWorkerFeed = {
42
+ async sendMessage(_chatId, text) {
43
+ const message_id = ++nextId
44
+ painted.push({ kind: 'send', messageId: message_id, text })
45
+ return { message_id }
46
+ },
47
+ async editMessageText(_chatId, messageId, text) {
48
+ painted.push({ kind: 'edit', messageId, text })
49
+ return {}
50
+ },
51
+ }
52
+ const feed = createWorkerActivityFeed({
53
+ bot,
54
+ now: () => now,
55
+ // Zero the rate limiters so every cue paints — this suite is about the
56
+ // RENDERED BYTES, not the edit budget (that is worker-feed-coalesce's job).
57
+ minEditIntervalMs: 0,
58
+ elapsedRefreshMs: 0,
59
+ firstPaintMinMs: 0,
60
+ maxRows: opts.maxRows ?? 6,
61
+ groupMessageLifetimeCapMs: opts.groupMessageLifetimeCapMs,
62
+ // No real timers in the test process.
63
+ setInterval: () => null,
64
+ clearInterval: () => {},
65
+ })
66
+ return {
67
+ feed,
68
+ painted,
69
+ advance: (ms: number) => {
70
+ now += ms
71
+ },
72
+ nowMs: () => now,
73
+ /** The most recent body sent or edited — what the user is looking at. */
74
+ last: (): string => painted[painted.length - 1]?.text ?? '',
75
+ reset: () => {
76
+ painted.length = 0
77
+ },
78
+ }
79
+ }
80
+
81
+ const view = (over: Partial<WorkerActivityView> = {}): WorkerActivityView => ({
82
+ description: 'Audit every card render path',
83
+ state: 'running',
84
+ elapsedMs: 12_000,
85
+ toolCount: 3,
86
+ lastTool: null,
87
+ latestSummary: '',
88
+ narrativeLines: [],
89
+ model: 'claude-opus-4-8',
90
+ totalTokens: 10_000,
91
+ ...over,
92
+ })
93
+
94
+ /** Await the feed's internal promise chain. */
95
+ const settle = async (): Promise<void> => {
96
+ for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
97
+ }
98
+
99
+ describe('worker card lifecycle — the bytes the user actually sees', () => {
100
+ it('dispatch: the first paint is a real card with a starting… placeholder, not a bare header', async () => {
101
+ const h = harness()
102
+ await h.feed.update('w1', CHAT, view({ elapsedMs: 1_200, toolCount: 0, narrativeLines: [] }))
103
+ await settle()
104
+
105
+ expect(h.painted.length).toBeGreaterThan(0)
106
+ expect(h.painted[0].kind).toBe('send')
107
+ const card = h.painted[0].text
108
+ // Title line, metrics line, placeholder — three lines, all from the layout core.
109
+ expect(card).toContain('🛠 **WORKER**')
110
+ expect(card).toContain('Audit every card render path')
111
+ expect(card).toMatch(/_1s · 0 tools · 10\.0k tok · opus 4\.8_/)
112
+ expect(card).toContain('_starting…_')
113
+ // #3846: the placeholder is a SECTION of the card, so it carries the same
114
+ // collapse-safe line joining as every other line — a hand-appended tail did not.
115
+ const lines = card.split('\n')
116
+ expect(lines.length).toBe(3)
117
+ for (const l of lines.slice(0, -1)) expect(l.endsWith(' ')).toBe(true)
118
+ expect(card).not.toContain('starting…\n_starting…_')
119
+ })
120
+
121
+ it('running: narrative steps become a ✓/→ trail under one shared header', async () => {
122
+ const h = harness()
123
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'Fetching origin' }))
124
+ await settle()
125
+ h.reset()
126
+ // The watcher emits ONE latest step per tick; the feed accumulates them.
127
+ await h.feed.update('w1', CHAT, view({ toolCount: 10, latestSummary: 'Reading the renderer' }))
128
+ await settle()
129
+ await h.feed.update(
130
+ 'w1',
131
+ CHAT,
132
+ view({ elapsedMs: 143_000, toolCount: 31, totalTokens: 88_400, latestSummary: 'Mapping callers' }),
133
+ )
134
+ await settle()
135
+
136
+ const card = h.last()
137
+ expect(card).toContain('~~_✓ Fetching origin_~~')
138
+ expect(card).toContain('~~_✓ Reading the renderer_~~')
139
+ expect(card).toContain('**→ Mapping callers**')
140
+ expect(card).not.toContain('_starting…_')
141
+ // The metrics line is composed once, by metricsRun — running order is
142
+ // `{elapsed} · {tools} · {tok} · {model}`.
143
+ expect(card).toContain('_2m23s · 31 tools · 88.4k tok · opus 4.8_')
144
+ // Single-worker card carries NO indent (#3843 de-indent).
145
+ expect(card).not.toContain(WORKER_STEP_INDENT)
146
+ })
147
+
148
+ it('depth budget: a row deeper than its per-worker budget shows only its newest steps', async () => {
149
+ // The feed retains WORKER_HISTORY_MAX (6) steps; with 2 workers the shared
150
+ // layout gives each row a depth of 5, so the oldest step must drop out.
151
+ const h = harness()
152
+ for (let i = 1; i <= 6; i++) {
153
+ await h.feed.update('w1', CHAT, view({ toolCount: i, latestSummary: `s${i}` }))
154
+ await settle()
155
+ }
156
+ await h.feed.update('w2', CHAT, view({ description: 'Second worker', latestSummary: 'b1' }))
157
+ await settle()
158
+
159
+ const card = h.last()
160
+ expect(card).not.toContain('s1_')
161
+ expect(card).toContain(`${WORKER_STEP_INDENT}~~_✓ s2_~~`)
162
+ expect(card).toContain(`${WORKER_STEP_INDENT}**→ s6**`)
163
+ // Exactly depth-1 done steps plus the one live step.
164
+ expect(card.split('\n').filter((l) => /~~_✓ s\d_~~/.test(l)).length).toBe(4)
165
+ expect(card.split('\n').filter((l) => l.includes('**→ ')).length).toBe(2)
166
+ })
167
+
168
+ it('two workers coalesce into ONE message whose rows share the header composer', async () => {
169
+ const h = harness()
170
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
171
+ await settle()
172
+ const firstId = h.painted[0].messageId
173
+ h.reset()
174
+ await h.feed.update(
175
+ 'w2',
176
+ CHAT,
177
+ view({
178
+ description: 'Fix the hindsight recall floor',
179
+ elapsedMs: 60_000,
180
+ toolCount: 7,
181
+ totalTokens: 20_000,
182
+ latestSummary: 'recall 1',
183
+ }),
184
+ )
185
+ await settle()
186
+
187
+ // One message, edited — not a second send.
188
+ expect(h.painted.every((p) => p.messageId === firstId)).toBe(true)
189
+ expect(h.painted.some((p) => p.kind === 'send')).toBe(false)
190
+
191
+ const card = h.last()
192
+ expect(card).toContain('🛠 **WORKERS**')
193
+ expect(card).toContain('2 running')
194
+ expect(card).toContain('**1. Audit every card render path**')
195
+ expect(card).toContain('**2. Fix the hindsight recall floor**')
196
+ // Row headers use the SAME metricsRun as the single-worker card.
197
+ expect(card).toContain('_· 1m00s · 7 tools · 20.0k tok · opus 4.8_')
198
+ // Rows are indented with the braille blank so Telegram cannot left-trim.
199
+ expect(card).toContain(`${WORKER_STEP_INDENT}**→ recall 1**`)
200
+ })
201
+
202
+ it('a worker with no history yet renders the shared placeholder inside its row', async () => {
203
+ const h = harness()
204
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
205
+ await settle()
206
+ h.reset()
207
+ await h.feed.update('w2', CHAT, view({ description: 'Freshly dispatched', latestSummary: '' }))
208
+ await settle()
209
+
210
+ const card = h.last()
211
+ expect(card).toContain('**2. Freshly dispatched**')
212
+ expect(card).toContain(`${WORKER_STEP_INDENT}→ _starting…_`)
213
+ })
214
+
215
+ it('spill: beyond maxRows the newest rows collapse into one +M more working… line', async () => {
216
+ const h = harness({ maxRows: 6 })
217
+ for (let i = 1; i <= 8; i++) {
218
+ await h.feed.update(`w${i}`, CHAT, view({ description: `Background task ${i}`, latestSummary: `t${i} a` }))
219
+ await settle()
220
+ }
221
+ const card = h.last()
222
+ expect(card).toContain('8 running')
223
+ expect(card).toContain('**6. Background task 6**')
224
+ expect(card).not.toContain('**7. Background task 7**')
225
+ expect(card).toContain('_+2 more working…_')
226
+ // The spill line is the LAST line of the card (a footer, not a stray row).
227
+ const lines = card.split('\n')
228
+ expect(lines[lines.length - 1]).toContain('+2 more working…')
229
+ })
230
+
231
+ it('spill honours the body-line budget: many deep rows still shrink to fit', async () => {
232
+ const h = harness({ maxRows: 6 })
233
+ for (let i = 1; i <= 6; i++) {
234
+ await h.feed.update(
235
+ `w${i}`,
236
+ CHAT,
237
+ view({
238
+ description: `Background task ${i}`,
239
+ latestSummary: `t${i} step`,
240
+ }),
241
+ )
242
+ await settle()
243
+ for (const step of ['b', 'c', 'd', 'e', 'f']) {
244
+ await h.feed.update(`w${i}`, CHAT, view({ description: `Background task ${i}`, latestSummary: `t${i} ${step}` }))
245
+ await settle()
246
+ }
247
+ }
248
+ const card = h.last()
249
+ // MAX_COMBINED_BODY_LINES = 6 rows × (1 header + 3 steps) = 24.
250
+ const body = card.split('\n').filter((l) => !l.includes('🛠 **WORKERS**') && !l.includes('more working…'))
251
+ expect(body.length).toBeLessThanOrEqual(24)
252
+ // Shrinking drops ROWS, never the glance chrome.
253
+ expect(card).toContain('🛠 **WORKERS**')
254
+ expect(card).toContain('6 running')
255
+ })
256
+
257
+ it('one worker finishing leaves the survivors numbered as they were dispatched', async () => {
258
+ const h = harness()
259
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
260
+ await settle()
261
+ await h.feed.update('w2', CHAT, view({ description: 'Second worker', latestSummary: 'b1' }))
262
+ await settle()
263
+ await h.feed.update('w3', CHAT, view({ description: 'Third worker', latestSummary: 'c1' }))
264
+ await settle()
265
+ expect(h.last()).toContain('**1. Audit every card render path**')
266
+ h.reset()
267
+
268
+ await h.feed.finish('w1', view({ state: 'done', latestSummary: 'All done.' }))
269
+ await settle()
270
+
271
+ const card = h.last()
272
+ expect(card).toContain('2 running')
273
+ expect(card).toContain('**2. Second worker**')
274
+ expect(card).toContain('**3. Third worker**')
275
+ expect(card).not.toContain('**1. ')
276
+ })
277
+
278
+ it('completion: the last worker collapses to a struck feed plus the ✅ result recap', async () => {
279
+ const h = harness()
280
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'Fetching origin' }))
281
+ await settle()
282
+ await h.feed.update('w1', CHAT, view({ toolCount: 20, latestSummary: 'Mapping callers' }))
283
+ await settle()
284
+ h.reset()
285
+ await h.feed.finish(
286
+ 'w1',
287
+ view({
288
+ state: 'done',
289
+ elapsedMs: 902_000,
290
+ toolCount: 44,
291
+ latestSummary: 'Inventoried every card variant and unified the renderer. PR #0000.',
292
+ }),
293
+ )
294
+ await settle()
295
+
296
+ const card = h.last()
297
+ // Terminal metrics order flips: `{state} · {tools} · {tok} · {elapsed}`.
298
+ expect(card).toContain('_done · 44 tools · 10.0k tok · 15m02s · opus 4.8_')
299
+ // No live `→` marker survives on a finished card.
300
+ expect(card).not.toContain('**→ ')
301
+ expect(card).toContain('~~_✓ Mapping callers_~~')
302
+ // The result block: shared rule, then the ✅ recap paragraph.
303
+ expect(card).toContain('─────')
304
+ expect(card).toContain('✅ _Inventoried')
305
+ expect(card).toContain('Inventoried every card variant and unified the renderer.')
306
+ })
307
+
308
+ it('failure: the card reads failed and carries the error recap', async () => {
309
+ const h = harness()
310
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'Running the scoped suite' }))
311
+ await settle()
312
+ h.reset()
313
+ await h.feed.finish(
314
+ 'w1',
315
+ view({
316
+ state: 'failed',
317
+ elapsedMs: 61_000,
318
+ toolCount: 9,
319
+ latestSummary: 'Scoped test run failed: 3 assertions.',
320
+ }),
321
+ )
322
+ await settle()
323
+
324
+ const card = h.last()
325
+ expect(card).toContain('_failed · 9 tools · 10.0k tok · 1m01s · opus 4.8_')
326
+ expect(card).toContain('⚠️ _Scoped test run failed: 3 assertions._')
327
+ expect(card).not.toContain('**→ ')
328
+ })
329
+
330
+ it('supersede: the retired message is finalised to the shared-chrome notice, and a fresh card opens', async () => {
331
+ const h = harness({ groupMessageLifetimeCapMs: 60_000 })
332
+ await h.feed.update('w1', CHAT, view({ latestSummary: 'step one' }))
333
+ await settle()
334
+ const firstId = h.painted[0].messageId
335
+ h.reset()
336
+
337
+ // Push the group's shared message past its lifetime cap, then let the
338
+ // heartbeat (the real rotation trigger) run.
339
+ h.advance(120_000)
340
+ await h.feed.update('w1', CHAT, view({ elapsedMs: 132_000, toolCount: 9, latestSummary: 'step two' }))
341
+ await settle()
342
+ h.feed.heartbeatTick()
343
+ await settle()
344
+
345
+ const superseded = h.painted.find((p) => p.text === WORKER_CARD_SUPERSEDED_BODY)
346
+ expect(superseded, 'the retired message must be finalised to the superseded notice').toBeDefined()
347
+ expect(superseded?.kind).toBe('edit')
348
+ expect(superseded?.messageId).toBe(firstId)
349
+ // Shared chrome: same title composer as every live card.
350
+ expect(WORKER_CARD_SUPERSEDED_BODY.startsWith('🛠 **WORKER** · _continued_')).toBe(true)
351
+ // The retired notice carries no live-styled rows — it must not read as a
352
+ // stuck worker.
353
+ expect(WORKER_CARD_SUPERSEDED_BODY).not.toContain('**→ ')
354
+
355
+ // A fresh, live card was opened for the still-running worker.
356
+ const fresh = h.painted.filter((p) => p.kind === 'send')
357
+ expect(fresh.length).toBe(1)
358
+ expect(fresh[0].messageId).not.toBe(firstId)
359
+ expect(fresh[0].text).toContain('🛠 **WORKER**')
360
+ expect(fresh[0].text).toContain('step two')
361
+ })
362
+ })
@@ -0,0 +1,291 @@
1
+ /**
2
+ * #3842 — every status card is FLUSH at the left margin, and the only
3
+ * indentation any card carries is the step indent that separates one worker's
4
+ * steps from the next worker's on the combined (2+ worker) card.
5
+ *
6
+ * #3820/#3821 had made the worker card structurally subordinate to the 🤖 agent
7
+ * card: line 1 prefixed with `└─ ` and every later line indented by a U+2800
8
+ * run. Ken rejected that shape — it burns a level of horizontal space on a
9
+ * phone, and the worker card does not always sit below the agent card, so a
10
+ * card-LEVEL subordination marker asserts a relationship that is not always
11
+ * true. Net effect of this file's expectations: everything shifted left by one
12
+ * level; worker-vs-worker separation is carried by the surviving step indent
13
+ * plus the numbered row headers.
14
+ *
15
+ * These tests assert the RENDERED OUTPUT of the real renderers, and they fail
16
+ * on the pre-#3842 shape (which put `└─ ` on line 1 and U+2800 on every other
17
+ * line of both worker surfaces).
18
+ *
19
+ * What is NOT asserted here any more, deliberately: that no rendered line is
20
+ * shared between the agent card and the worker card. With whole-card nesting
21
+ * gone and identical step text, the two cards' BODY lines coincide byte for
22
+ * byte — see the explicit characterization test at the end. Line 1 (🤖 `Agent`
23
+ * vs 🛠 `WORKER`) is now the only type cue, which is the accepted trade.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import {
27
+ renderActivityFeed,
28
+ renderStatusCard,
29
+ renderCombinedWorkerFeed,
30
+ } from '../tool-activity-summary.js'
31
+ import { renderWorkerActivity, type WorkerActivityView } from '../worker-activity-feed.js'
32
+ import { WORKER_STEP_INDENT, STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
33
+ import { richMessage } from '../rich-send.js'
34
+
35
+ /** Split a rendered card into display lines, dropping the hard-break padding. */
36
+ function lines(card: string): string[] {
37
+ return card.split('\n').map((l) => l.replace(/[  \t\r]+$/, ''))
38
+ }
39
+
40
+ /** The exact scenario from #3820: parent narrating the task it delegated. */
41
+ const STEPS = ['PR 2502 details', 'Commits since v0.8.5']
42
+
43
+ /** The card-level chrome #3842 removed. Asserted absent, never re-emitted. */
44
+ const REMOVED_HEADER_PREFIX = '└─ '
45
+
46
+ function agentCard(): string {
47
+ const out = renderActivityFeed(STEPS, false, '', undefined, {
48
+ label: 'Agent',
49
+ elapsedMs: 108_000,
50
+ toolCount: 15,
51
+ state: 'running',
52
+ model: 'claude-opus-5',
53
+ totalTokens: 16_400,
54
+ })
55
+ expect(out).not.toBeNull()
56
+ return out as string
57
+ }
58
+
59
+ function workerView(over: Partial<WorkerActivityView> = {}): WorkerActivityView {
60
+ return {
61
+ description: 'Check upstream hindsight changes',
62
+ elapsedMs: 108_000,
63
+ toolCount: 15,
64
+ latestSummary: '',
65
+ narrativeLines: STEPS,
66
+ state: 'running',
67
+ model: 'claude-opus-5',
68
+ totalTokens: 16_400,
69
+ ...over,
70
+ } as WorkerActivityView
71
+ }
72
+
73
+ function combinedCard(): string {
74
+ const card = renderCombinedWorkerFeed(
75
+ [
76
+ {
77
+ description: 'Check upstream hindsight changes',
78
+ elapsedMs: 55_000,
79
+ toolCount: 9,
80
+ currentStep: 'Commits since v0.8.5',
81
+ historyLines: STEPS,
82
+ ordinal: 1,
83
+ },
84
+ {
85
+ description: 'Fix the card renderer',
86
+ elapsedMs: 30_000,
87
+ toolCount: 4,
88
+ currentStep: 'Reading render.ts',
89
+ historyLines: ['Reading render.ts'],
90
+ ordinal: 2,
91
+ },
92
+ ],
93
+ { maxRows: 4 },
94
+ )
95
+ expect(card).not.toBeNull()
96
+ return card as string
97
+ }
98
+
99
+ /** Every worker surface, in each of its render states, as rendered strings. */
100
+ function everyWorkerSurface(): Array<{ name: string; card: string }> {
101
+ return [
102
+ { name: 'running', card: renderWorkerActivity(workerView()) },
103
+ {
104
+ name: 'just dispatched',
105
+ card: renderWorkerActivity(workerView({ narrativeLines: [], latestSummary: '' })),
106
+ },
107
+ {
108
+ name: 'done + result block',
109
+ card: renderWorkerActivity(
110
+ workerView({ state: 'done', latestSummary: 'Rebased and opened the PR.' }),
111
+ ),
112
+ },
113
+ {
114
+ name: 'failed + result block',
115
+ card: renderWorkerActivity(
116
+ workerView({ state: 'failed', latestSummary: 'Scoped tests went red.' }),
117
+ ),
118
+ },
119
+ {
120
+ name: 'incomplete',
121
+ card: renderWorkerActivity(workerView({ state: 'incomplete', latestSummary: '' })),
122
+ },
123
+ { name: 'combined (2 workers)', card: combinedCard() },
124
+ ]
125
+ }
126
+
127
+ describe('#3842 every card is flush at the left margin', () => {
128
+ it('emits no `└─ ` header prefix on any surface', () => {
129
+ for (const { name, card } of [{ name: 'agent', card: agentCard() }, ...everyWorkerSurface()]) {
130
+ expect(card.includes(REMOVED_HEADER_PREFIX), `${name} card carries └─`).toBe(false)
131
+ }
132
+ })
133
+
134
+ it('leaves every line of the SINGLE-worker card unindented', () => {
135
+ // Pre-#3842 line 1 started `└─ ` and lines 2..n started with a U+2800 run.
136
+ const surfaces = everyWorkerSurface()
137
+ for (const name of [
138
+ 'running',
139
+ 'just dispatched',
140
+ 'done + result block',
141
+ 'failed + result block',
142
+ 'incomplete',
143
+ ]) {
144
+ const card = surfaces.find((s) => s.name === name)?.card as string
145
+ const l = lines(card)
146
+ expect(l.length).toBeGreaterThan(1)
147
+ for (const line of l) {
148
+ expect(line.startsWith(WORKER_STEP_INDENT), `${name}: "${line}"`).toBe(false)
149
+ }
150
+ }
151
+ })
152
+
153
+ it('keeps the just-dispatched `starting…` tail flush too', () => {
154
+ // The one render state a user always sees first takes a hand-rolled append
155
+ // path outside renderStatusCard — the place a shape change silently misses.
156
+ const l = lines(renderWorkerActivity(workerView({ narrativeLines: [], latestSummary: '' })))
157
+ expect(l[0].startsWith('🛠 **WORKER**')).toBe(true)
158
+ expect(l[l.length - 1]).toBe('_starting…_')
159
+ })
160
+
161
+ it('keeps the terminal result block flush', () => {
162
+ const l = lines(
163
+ renderWorkerActivity(
164
+ workerView({ state: 'done', latestSummary: 'Rebased and opened the PR.' }),
165
+ ),
166
+ )
167
+ expect(l.some((x) => x.startsWith('✅ '))).toBe(true)
168
+ expect(l.some((x) => x.startsWith(`${WORKER_STEP_INDENT}✅ `))).toBe(false)
169
+ })
170
+ })
171
+
172
+ describe('#3842 the combined card keeps exactly ONE level of indent', () => {
173
+ it('puts the glance line and row headers flush', () => {
174
+ const l = lines(combinedCard())
175
+ expect(l[0]).toBe('🛠 **WORKERS** · _2 running · oldest 55s · 13 tools_')
176
+ const rowHeaders = l.filter((x) => x.includes('**1. ') || x.includes('**2. '))
177
+ expect(rowHeaders.length).toBe(2)
178
+ for (const h of rowHeaders) expect(h.startsWith(WORKER_STEP_INDENT)).toBe(false)
179
+ })
180
+
181
+ it('indents step lines exactly one level — never two', () => {
182
+ const l = lines(combinedCard())
183
+ const stepLines = l.filter((x) => x.includes('→ ') || x.includes('✓ '))
184
+ expect(stepLines.length).toBeGreaterThan(0)
185
+ for (const s of stepLines) {
186
+ expect(s.startsWith(WORKER_STEP_INDENT)).toBe(true)
187
+ // The pre-#3842 double indent (SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT).
188
+ expect(s.startsWith(WORKER_STEP_INDENT + WORKER_STEP_INDENT)).toBe(false)
189
+ }
190
+ })
191
+
192
+ it('puts the `+M more working…` spill line flush', () => {
193
+ const rows = Array.from({ length: 5 }, (_, i) => ({
194
+ description: `task ${i + 1}`,
195
+ elapsedMs: 10_000,
196
+ toolCount: 1,
197
+ currentStep: 'working',
198
+ historyLines: ['working'],
199
+ ordinal: i + 1,
200
+ }))
201
+ const card = renderCombinedWorkerFeed(rows, { maxRows: 2 }) as string
202
+ const spill = lines(card).find((x) => x.includes('more working…'))
203
+ expect(spill).toBe('_+3 more working…_')
204
+ })
205
+ })
206
+
207
+ describe('#3842 char-budget accounting matches the flush shape', () => {
208
+ it('keeps an oversized worker card under the wire budget', () => {
209
+ // fitCardToBudget has its own line assembly + arithmetic. With the per-line
210
+ // nest cost removed it must still land under the cap, and must not emit any
211
+ // chrome the card no longer carries.
212
+ const huge = Array.from({ length: 500 }, (_, i) => `${i} `.repeat(100))
213
+ const card = renderStatusCard({
214
+ header: {
215
+ emoji: '🛠',
216
+ label: 'WORKER',
217
+ description: 'big',
218
+ elapsedMs: 1000,
219
+ toolCount: 1,
220
+ state: 'running',
221
+ },
222
+ steps: huge,
223
+ historyWindow: 6,
224
+ })
225
+ expect(card).not.toBeNull()
226
+ const s = card as string
227
+ expect(s.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
228
+ expect(s.includes(REMOVED_HEADER_PREFIX)).toBe(false)
229
+ for (const l of lines(s)) expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
230
+ })
231
+
232
+ it('keeps an oversized combined card under the wire budget', () => {
233
+ const rows = Array.from({ length: 6 }, (_, i) => ({
234
+ description: 'D'.repeat(72),
235
+ elapsedMs: 10_000,
236
+ toolCount: 3,
237
+ currentStep: 'x',
238
+ historyLines: Array.from({ length: 6 }, (_, k) => `${'s'.repeat(180)}-${i}-${k}`),
239
+ ordinal: i + 1,
240
+ }))
241
+ const card = renderCombinedWorkerFeed(rows, { maxRows: 6 }) as string
242
+ expect(card.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
243
+ })
244
+ })
245
+
246
+ describe('#3842 the surviving type cues', () => {
247
+ it('uses a high-contrast caps type label on the worker card only', () => {
248
+ const agent = agentCard()
249
+ const worker = renderWorkerActivity(workerView())
250
+ expect(worker).toContain('🛠 **WORKER**')
251
+ expect(agent).toContain('🤖 **Agent**')
252
+ expect(agent).not.toContain('WORKER')
253
+ })
254
+
255
+ it('distinguishes the two cards on LINE 1 — the only remaining cue', () => {
256
+ // Characterization, not aspiration: with whole-card nesting gone and
257
+ // identical step text + stats, the two cards' BODY lines coincide byte for
258
+ // byte. Line 1 is doing all of the work, so it must never converge.
259
+ const agent = lines(agentCard())
260
+ const worker = lines(renderWorkerActivity(workerView()))
261
+ expect(agent[0]).not.toBe(worker[0])
262
+ expect(agent[0].startsWith('🤖 ')).toBe(true)
263
+ expect(worker[0].startsWith('🛠 ')).toBe(true)
264
+ // The documented cost of #3842, pinned so a future change to it is visible:
265
+ // every line BELOW line 1 is now shared between the two cards.
266
+ const overlap = agent.filter((a) => worker.includes(a))
267
+ expect(overlap).toEqual(agent.slice(1))
268
+ })
269
+ })
270
+
271
+ describe('#3842 the surviving step indent survives Telegram reality', () => {
272
+ it('passes every card through the real outbound markdown guard byte-identical', () => {
273
+ // richMessage() is the ONE adapter every `{ markdown }` wire send funnels
274
+ // through (rich-send.ts) — it runs the line-start / heading / dollar-math
275
+ // guards. Chrome that got escaped there would render as literal markup.
276
+ for (const { card } of [{ card: agentCard() }, ...everyWorkerSurface()]) {
277
+ expect(richMessage(card).markdown).toBe(card)
278
+ }
279
+ })
280
+
281
+ it('uses an indent that no whitespace-trimming rule can eat (#3662 lesson)', () => {
282
+ // U+00A0 shipped in #3662 and was INERT: Telegram left-trims a leading
283
+ // Unicode-whitespace run, and U+00A0 is category Zs. Assert the PROPERTY,
284
+ // not the bytes — a byte-only assertion is what let #3662 ship green.
285
+ for (const ch of Array.from(WORKER_STEP_INDENT)) {
286
+ expect(/\s/.test(ch)).toBe(false)
287
+ expect(/\p{White_Space}/u.test(ch)).toBe(false)
288
+ expect(/\p{Zs}/u.test(ch)).toBe(false)
289
+ }
290
+ })
291
+ })