switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { GrammyError } from 'grammy'
3
+ import { decidePinAction } from '../status-pin.js'
4
+ import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
5
+ import type { PinState } from '../status-pin.js'
6
+
7
+ /** A real GrammyError with the given code + description, mirroring the wire
8
+ * shape Telegram returns for the pin-rights failure that crashed marko. */
9
+ function grammyError(error_code: number, description: string): GrammyError {
10
+ return new GrammyError(
11
+ `Call to 'pinChatMessage' failed!`,
12
+ { ok: false, error_code, description },
13
+ 'pinChatMessage',
14
+ {} as never,
15
+ )
16
+ }
17
+
18
+ describe('decidePinAction (pure)', () => {
19
+ it('pins an existing message when work goes in-flight (nothing pinned yet)', () => {
20
+ const action = decidePinAction(null, { pinned: true, messageId: 42 })
21
+ expect(action).toEqual({ kind: 'pin', messageId: 42 })
22
+ })
23
+
24
+ it('unpins when work completes (something pinned)', () => {
25
+ const action = decidePinAction({ messageId: 42 }, { pinned: false })
26
+ expect(action).toEqual({ kind: 'unpin', messageId: 42 })
27
+ })
28
+
29
+ it('noops when nothing is pinned and nothing is wanted', () => {
30
+ const action = decidePinAction(null, { pinned: false })
31
+ expect(action.kind).toBe('noop')
32
+ })
33
+
34
+ it('noops when the wanted message is already pinned', () => {
35
+ const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 42 })
36
+ expect(action.kind).toBe('noop')
37
+ })
38
+
39
+ it('unpins the stale claim when the wanted message id changed (feed re-posted)', () => {
40
+ const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 99 })
41
+ expect(action).toEqual({ kind: 'unpin', messageId: 42 })
42
+ })
43
+ })
44
+
45
+ /** A fake Bot API recording calls, with per-method throw toggles. */
46
+ function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
47
+ const calls: { verb: string; messageId: number; opts?: unknown }[] = []
48
+ const api: PinBotApi = {
49
+ pinChatMessage: async (_chat, message_id, o) => {
50
+ calls.push({ verb: 'pin', messageId: message_id, opts: o })
51
+ if (opts.pinThrows) throw new Error('pin failed')
52
+ },
53
+ unpinChatMessage: async (_chat, message_id) => {
54
+ calls.push({ verb: 'unpin', messageId: message_id })
55
+ if (opts.unpinThrows) throw new Error('unpin failed')
56
+ },
57
+ }
58
+ return { api, calls }
59
+ }
60
+
61
+ describe('reconcilePin (driver)', () => {
62
+ it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
63
+ const { api, calls } = fakeApi()
64
+ const next = await reconcilePin({
65
+ api,
66
+ chatId: '123',
67
+ prevState: null,
68
+ desired: { pinned: true, messageId: 42 },
69
+ })
70
+ expect(next).toEqual({ messageId: 42 })
71
+ expect(calls).toHaveLength(1)
72
+ expect(calls[0].verb).toBe('pin')
73
+ expect(calls[0].messageId).toBe(42)
74
+ // Silent — must never buzz the device.
75
+ expect((calls[0].opts as Record<string, unknown>).disable_notification).toBe(true)
76
+ })
77
+
78
+ it('unpin on finalize: unpins and drops the claim', async () => {
79
+ const { api, calls } = fakeApi()
80
+ const prev: PinState = { messageId: 42 }
81
+ const next = await reconcilePin({
82
+ api,
83
+ chatId: '123',
84
+ prevState: prev,
85
+ desired: { pinned: false },
86
+ })
87
+ expect(next).toBeNull()
88
+ expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
89
+ })
90
+
91
+ it('CRITICAL: drops the claim even when unpinChatMessage throws', async () => {
92
+ const errors: string[] = []
93
+ const { api, calls } = fakeApi({ unpinThrows: true })
94
+ const next = await reconcilePin({
95
+ api,
96
+ chatId: '123',
97
+ prevState: { messageId: 42 },
98
+ desired: { pinned: false },
99
+ onError: (phase) => errors.push(phase),
100
+ })
101
+ // State MUST be cleared even though the API threw — never stay stuck pinned.
102
+ expect(next).toBeNull()
103
+ expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
104
+ expect(errors).toEqual(['unpin'])
105
+ })
106
+
107
+ it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
108
+ const errors: string[] = []
109
+ const { api } = fakeApi({ pinThrows: true })
110
+ const next = await reconcilePin({
111
+ api,
112
+ chatId: '123',
113
+ prevState: null,
114
+ desired: { pinned: true, messageId: 42 },
115
+ onError: (phase) => errors.push(phase),
116
+ })
117
+ // No claim taken — prevState (null) is returned so the next reconcile retries.
118
+ expect(next).toBeNull()
119
+ expect(errors).toEqual(['pin'])
120
+ })
121
+
122
+ it('never sends a new message — only pin/unpin the existing id', async () => {
123
+ // The fake has no sendMessage; a driver that tried to send would throw.
124
+ const { api } = fakeApi()
125
+ await reconcilePin({ api, chatId: '1', prevState: null, desired: { pinned: true, messageId: 7 } })
126
+ await reconcilePin({ api, chatId: '1', prevState: { messageId: 7 }, desired: { pinned: false } })
127
+ expect(Object.keys(api)).toEqual(['pinChatMessage', 'unpinChatMessage'])
128
+ })
129
+ })
130
+
131
+ // ── Regression: the marko 2026-07-01 gateway crash ─────────────────────────
132
+ // A pinChatMessage 400 "not enough rights to manage pinned messages" (the bot
133
+ // is not an admin in a supergroup) escaped as an unhandledRejection and shut
134
+ // the whole gateway down. Auto status-pin is best-effort/cosmetic — a
135
+ // pin-rights failure must be absorbed, never fatal, and never reach the
136
+ // process-level unhandledRejection handler.
137
+ describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)', () => {
138
+ it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
139
+ const rightsErr = grammyError(
140
+ 400,
141
+ 'Bad Request: not enough rights to manage pinned messages in the chat',
142
+ )
143
+ const captured: { phase: string; code?: number }[] = []
144
+ const api: PinBotApi = {
145
+ pinChatMessage: async () => {
146
+ throw rightsErr
147
+ },
148
+ unpinChatMessage: async () => {},
149
+ }
150
+
151
+ // reconcilePin resolves (never rejects) even when the pin API throws a
152
+ // real GrammyError — this is the contract the fire-and-forget
153
+ // `void reconcileStatusPin(...)` callsites depend on to not leak a
154
+ // rejection to the process.
155
+ const next = await reconcilePin({
156
+ api,
157
+ chatId: '-1003831053471',
158
+ prevState: null,
159
+ desired: { pinned: true, messageId: 4 },
160
+ onError: (phase, err) =>
161
+ captured.push({
162
+ phase,
163
+ code: err instanceof GrammyError ? err.error_code : undefined,
164
+ }),
165
+ })
166
+
167
+ // No claim taken (pin failed), error routed through onError, promise
168
+ // resolved cleanly.
169
+ expect(next).toBeNull()
170
+ expect(captured).toEqual([{ phase: 'pin', code: 400 }])
171
+ })
172
+
173
+ it('a fire-and-forget reconcilePin does NOT emit an unhandledRejection', async () => {
174
+ const rejections: unknown[] = []
175
+ const onUnhandled = (err: unknown) => rejections.push(err)
176
+ process.on('unhandledRejection', onUnhandled)
177
+ try {
178
+ const api: PinBotApi = {
179
+ pinChatMessage: async () => {
180
+ throw grammyError(
181
+ 400,
182
+ 'Bad Request: not enough rights to manage pinned messages in the chat',
183
+ )
184
+ },
185
+ unpinChatMessage: async () => {},
186
+ }
187
+ // Fire-and-forget, exactly as the gateway's auto-pin callsites do.
188
+ void reconcilePin({
189
+ api,
190
+ chatId: '-1003831053471',
191
+ prevState: null,
192
+ desired: { pinned: true, messageId: 4 },
193
+ onError: () => {},
194
+ })
195
+ // Let microtasks + a macrotask flush so any leaked rejection would fire.
196
+ await new Promise((r) => setTimeout(r, 20))
197
+ expect(rejections).toEqual([])
198
+ } finally {
199
+ process.off('unhandledRejection', onUnhandled)
200
+ }
201
+ })
202
+ })
@@ -117,6 +117,45 @@ describe('handleStreamReply', () => {
117
117
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
118
118
  })
119
119
 
120
+ it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
121
+ const state = makeState()
122
+ // Spacer dep replaces every `\n\n` gap with a visible marker so we can
123
+ // assert the rich path ran it (mirrors the real gateway wiring).
124
+ const deps = makeDeps(bot, {
125
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
126
+ })
127
+
128
+ const pending = handleStreamReply(
129
+ { chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
130
+ state,
131
+ deps,
132
+ )
133
+ await microtaskFlush()
134
+ await pending
135
+
136
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
137
+ expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
138
+ })
139
+
140
+ it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
141
+ const state = makeState()
142
+ const deps = makeDeps(bot, {
143
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
144
+ })
145
+
146
+ const pending = handleStreamReply(
147
+ { chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
148
+ state,
149
+ deps,
150
+ )
151
+ await microtaskFlush()
152
+ await pending
153
+
154
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
155
+ // Literal path is byte-exact — no spacer injected.
156
+ expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
157
+ })
158
+
120
159
  it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
121
160
  const state = makeState()
122
161
  const deps = makeDeps(bot)
@@ -19,6 +19,8 @@ import {
19
19
  repairEscapedWhitespace,
20
20
  escapeMarkdown,
21
21
  splitMarkdownChunks,
22
+ addParagraphSpacers,
23
+ PARAGRAPH_SPACER,
22
24
  RICH_MESSAGE_MAX_CHARS,
23
25
  } from '../format.js'
24
26
 
@@ -63,6 +65,26 @@ describe('escapeMarkdown', () => {
63
65
  // `\*` in the input is backslash + star → both get escaped exactly once.
64
66
  expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
65
67
  })
68
+
69
+ // The de-dup pass (#2695) replaced ~12 local escaper copies with this one
70
+ // canonical function. Every consolidated caller now depends on this exact
71
+ // contract: the FULL metacharacter set in a single string, each escaped.
72
+ test('escapes the full metacharacter set in one combined string', () => {
73
+ // Backslash placed last so it doesn't re-escape the chars after it.
74
+ expect(escapeMarkdown('`*_~=[]|\\')).toBe('\\`\\*\\_\\~\\=\\[\\]\\|\\\\')
75
+ })
76
+
77
+ test('already-safe text passes through unchanged', () => {
78
+ const safe = 'Switched fleet to alice (5h window 29% resets in 2h)'
79
+ expect(escapeMarkdown(safe)).toBe(safe)
80
+ })
81
+
82
+ // card-format.ts re-exports this same symbol; the consolidated callers
83
+ // import from one or the other. Lock that they are the identical function.
84
+ test('card-format re-exports the identical canonical escaper', async () => {
85
+ const cardFormat = await import('../card-format.js')
86
+ expect(cardFormat.escapeMarkdown).toBe(escapeMarkdown)
87
+ })
66
88
  })
67
89
 
68
90
  // ---------------------------------------------------------------------------
@@ -138,6 +160,72 @@ describe('splitMarkdownChunks', () => {
138
160
  expect(chunks.length).toBe(1)
139
161
  expect(chunks[0]).toBe(giant)
140
162
  })
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Chunk-boundary spacer hygiene. addParagraphSpacers injects a
166
+ // `\n\n${PARAGRAPH_SPACER}\n\n` gap between prose paragraphs. When a cut
167
+ // lands inside that gap, the boundary must not leave a chunk that opens or
168
+ // ends with a bare U+00A0 spacer line (a stray blank bubble line).
169
+ // -------------------------------------------------------------------------
170
+
171
+ test('no chunk starts or ends with a bare U+00A0 spacer line (reviewer repro)', () => {
172
+ // The exact reviewer repro: a spacer gap straddling a small cap.
173
+ const A = 'Alpha sentence one'
174
+ const B = 'Bravo sentence two'
175
+ const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
176
+ const chunks = splitMarkdownChunks(spaced, 33)
177
+ expect(chunks.length).toBeGreaterThan(1)
178
+ const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
179
+ for (const c of chunks) {
180
+ const lines = c.split('\n')
181
+ expect(spacerOnly.test(lines[0])).toBe(false)
182
+ expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
183
+ }
184
+ })
185
+
186
+ test('visible paragraph content survives the boundary (no text dropped)', () => {
187
+ const A = 'Alpha sentence one'
188
+ const B = 'Bravo sentence two'
189
+ const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
190
+ const chunks = splitMarkdownChunks(spaced, 33)
191
+ const rejoined = chunks.join('\n')
192
+ expect(rejoined).toContain(`${A}.`)
193
+ expect(rejoined).toContain(`${B}.`)
194
+ })
195
+
196
+ test('spacer-boundary strip is robust across several gaps and small caps', () => {
197
+ const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
198
+ const spaced = addParagraphSpacers(paras.join('\n\n'))
199
+ const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
200
+ for (const cap of [20, 31, 40, 64]) {
201
+ const chunks = splitMarkdownChunks(spaced, cap)
202
+ for (const c of chunks) {
203
+ const lines = c.split('\n')
204
+ expect(spacerOnly.test(lines[0])).toBe(false)
205
+ expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
206
+ }
207
+ // No visible word is dropped: concatenating the chunks' non-blank,
208
+ // non-spacer tokens reproduces the original word sequence. (Word-level,
209
+ // not line-level, because a small cap may split mid-word — that's the
210
+ // chunker's normal space-boundary behaviour, orthogonal to spacers.)
211
+ const words = (s: string): string[] =>
212
+ s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
213
+ // Join chunks with a space — each chunk is a separate Telegram message,
214
+ // so inter-chunk whitespace is irrelevant; what matters is no word is
215
+ // lost or fused. (A small cap may end a chunk mid-sentence at a space
216
+ // boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
217
+ expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
218
+ }
219
+ })
220
+
221
+ test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
222
+ const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
223
+ const chunks = splitMarkdownChunks(text, 40)
224
+ // No spacer was ever present, so no chunk gains/loses anything beyond the
225
+ // normal leading-newline strip; content is preserved.
226
+ const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
227
+ expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
228
+ })
141
229
  })
142
230
 
143
231
  // ---------------------------------------------------------------------------
@@ -84,10 +84,10 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
84
84
  "**→ Reading gateway.ts**",
85
85
  );
86
86
  expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
87
- "_✓ Reading gateway.ts_\n**→ Searching memory**",
87
+ "~~_✓ Reading gateway.ts_~~ \n**→ Searching memory**",
88
88
  );
89
89
  expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
90
- "_✓ Reading gateway.ts_\n_✓ Searching memory_\n**→ List workspace**",
90
+ "~~_✓ Reading gateway.ts_~~ \n~~_✓ Searching memory_~~ \n**→ List workspace**",
91
91
  );
92
92
  });
93
93
 
@@ -110,14 +110,14 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
110
110
  const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
111
111
  const out = renderActivityFeed(lines)!;
112
112
  const hidden = total - STATUS_ROLLING_LINES;
113
- expect(out.startsWith(`_✓ +${hidden} earlier…_\n`)).toBe(true);
113
+ expect(out.startsWith(`_✓ +${hidden} earlier…_ \n`)).toBe(true);
114
114
  // Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
115
115
  const firstVisible = total - STATUS_ROLLING_LINES + 1;
116
- expect(out).toContain(`_✓ Action ${firstVisible}_`);
116
+ expect(out).toContain(`~~_✓ Action ${firstVisible}_~~`);
117
117
  expect(out).not.toContain(`Action ${firstVisible - 1}<`);
118
118
  // The newest action is the in-progress step (bold →); the rest are done (✓).
119
119
  expect(out).toContain(`**→ Action ${total}**`);
120
- expect(out).toContain(`_✓ Action ${total - 1}_`);
120
+ expect(out).toContain(`~~_✓ Action ${total - 1}_~~`);
121
121
  expect(out).not.toContain(`**→ Action ${total - 1}**`);
122
122
  });
123
123
 
@@ -145,26 +145,46 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
145
145
  const lines = ["Reading a.ts", "Searching memory", "Running a command"];
146
146
  const out = renderActivityFeed(lines, true)!;
147
147
  expect(out).toBe(
148
- "_✓ Reading a.ts_\n_✓ Searching memory_\n_✓ Running a command_",
148
+ "~~_✓ Reading a.ts_~~ \n~~_✓ Searching memory_~~ \n~~_✓ Running a command_~~",
149
149
  );
150
150
  expect(out).not.toContain("→"); // no in-progress arrow anywhere
151
151
  });
152
152
 
153
153
  it("final=true on a single line is also done (✓)", () => {
154
- expect(renderActivityFeed(["Reading a.ts"], true)).toBe("_✓ Reading a.ts_");
154
+ expect(renderActivityFeed(["Reading a.ts"], true)).toBe("~~_✓ Reading a.ts_~~");
155
155
  });
156
156
 
157
157
  it("final defaults false (live render keeps the → in-progress newest line)", () => {
158
158
  expect(renderActivityFeed(["Reading a.ts"])).toBe("**→ Reading a.ts**");
159
159
  });
160
160
 
161
+ // Strikethrough contract (operator request): done (✓) step lines render with
162
+ // GFM strikethrough wrapping the italic so completed items visually recede,
163
+ // while the active (→ bold) line is never struck.
164
+ it("done step lines are struck (~~) AND italic; the active → line is not struck", () => {
165
+ const out = renderActivityFeed(["Reading a.ts", "Searching memory"])!;
166
+ // earlier step is done → struck italic
167
+ expect(out).toContain("~~_✓ Reading a.ts_~~");
168
+ // newest step is active → bold, NOT struck
169
+ expect(out).toContain("**→ Searching memory**");
170
+ expect(out).not.toContain("~~_✓ Searching memory_~~");
171
+ expect(out).not.toContain("~~**→");
172
+ // the meta/footer count lines stay UNSTRUCK (kept legible as meta)
173
+ const overflow = Array.from({ length: STATUS_ROLLING_LINES + 2 }, (_, i) => `Action ${i + 1}`);
174
+ const finalOut = renderActivityFeed(overflow, true, "", overflow.length)!;
175
+ expect(finalOut).toContain(`_✓ +${overflow.length - STATUS_ROLLING_LINES} earlier…_`);
176
+ expect(finalOut).toContain(`_✓ ${overflow.length} steps_`);
177
+ expect(finalOut).not.toContain(`~~_✓ +`);
178
+ expect(finalOut).not.toContain(`~~_✓ ${overflow.length} steps_~~`);
179
+ });
180
+
161
181
  // liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
162
182
  // long single step visibly advances ("→ Pulling Meta data · 18s") even though
163
183
  // the feed is pull-only and no new tool label arrived.
164
184
  describe("liveSuffix (heartbeat)", () => {
165
185
  it("appends the suffix to the newest in-progress line only", () => {
166
186
  expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
167
- "_✓ Reading a.ts_\n**→ Running a command · 18s**",
187
+ "~~_✓ Reading a.ts_~~ \n**→ Running a command · 18s**",
168
188
  );
169
189
  });
170
190
  it("single live line gets the suffix", () => {
@@ -176,7 +196,7 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
176
196
  const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
177
197
  expect(out).not.toContain("·");
178
198
  expect(out).not.toContain("→");
179
- expect(out).toBe("_✓ Reading a.ts_\n_✓ Running a command_");
199
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n~~_✓ Running a command_~~");
180
200
  });
181
201
  it("default empty suffix is byte-identical to no suffix", () => {
182
202
  expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
@@ -191,7 +211,7 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
191
211
  const lines: string[] = [];
192
212
  expect(appendActivityLabel(lines, "Searching memory")).toBe("**→ Searching memory**");
193
213
  expect(appendActivityLabel(lines, "List workspace")).toBe(
194
- "_✓ Searching memory_\n**→ List workspace**",
214
+ "~~_✓ Searching memory_~~ \n**→ List workspace**",
195
215
  );
196
216
  // consecutive dup collapses
197
217
  appendActivityLabel(lines, "List workspace");
@@ -238,7 +258,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
238
258
  const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
239
259
  const out = renderActivityFeed(lines, true, "", 5)!;
240
260
  // All lines are done (✓) and the footer is appended.
241
- expect(out).toContain("_✓ Running a command_");
261
+ expect(out).toContain("~~_✓ Running a command_~~");
242
262
  expect(out).toContain("_✓ 5 steps_");
243
263
  expect(out.endsWith("_✓ 5 steps_")).toBe(true);
244
264
  });
@@ -247,7 +267,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
247
267
  const lines = ["Reading CLAUDE.md"];
248
268
  const out = renderActivityFeed(lines, true, "", 0)!;
249
269
  expect(out).not.toContain("steps");
250
- expect(out).toBe("_✓ Reading CLAUDE.md_");
270
+ expect(out).toBe("~~_✓ Reading CLAUDE.md_~~");
251
271
  });
252
272
 
253
273
  it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
@@ -307,11 +327,11 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
307
327
  const child = ["Reading schema.ts", "Looking for foreign keys"];
308
328
  const out = renderActivityFeedWithNested(parent, child)!;
309
329
  // Parent is blocked at the Task tool → none of its lines is the live step.
310
- expect(out).toContain("_✓ Searching memory_");
311
- expect(out).toContain("_✓ Delegating: review the migration_");
330
+ expect(out).toContain("~~_✓ Searching memory_~~");
331
+ expect(out).toContain("~~_✓ Delegating: review the migration_~~");
312
332
  expect(out).not.toContain("**→ Delegating");
313
333
  // The live → step is the newest nested child line; earlier child = italic.
314
- expect(out).toContain(" ↳ _Reading schema.ts_");
334
+ expect(out).toContain(" ↳ ~~_Reading schema.ts_~~");
315
335
  expect(out).toContain(" ↳ **→ Looking for foreign keys**");
316
336
  });
317
337
 
@@ -342,13 +362,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
342
362
  ["Reading schema.ts", "Looking for foreign keys"],
343
363
  true,
344
364
  )!;
345
- expect(out).toContain(" ↳ _Looking for foreign keys_"); // newest now italic done
365
+ expect(out).toContain(" ↳ ~~_Looking for foreign keys_~~"); // newest now struck italic done
346
366
  expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
347
367
  });
348
368
 
349
369
  it("final=true with no children delegates to the finalized flat render", () => {
350
370
  expect(renderActivityFeedWithNested(["Reading a.ts"], [], true)).toBe(
351
- "_✓ Reading a.ts_",
371
+ "~~_✓ Reading a.ts_~~",
352
372
  );
353
373
  });
354
374
 
@@ -381,7 +401,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
381
401
  it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
382
402
  const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
383
403
  expect(out).toContain("_✓ 3 steps_");
384
- expect(out).toBe("_✓ Reading a.ts_\n_✓ 3 steps_");
404
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n_✓ 3 steps_");
385
405
  });
386
406
 
387
407
  // Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
@@ -398,7 +418,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
398
418
 
399
419
  it("finalizes the placeholder to a done record (no frozen → line)", () => {
400
420
  expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
401
- "_✓ Working…_",
421
+ "~~_✓ Working…_~~",
402
422
  );
403
423
  });
404
424
 
@@ -612,8 +632,8 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
612
632
  expect(out).not.toBeNull();
613
633
  expect(out.length).toBeLessThanOrEqual(4000);
614
634
  expect(isValidMarkdown(out)).toBe(true);
615
- expect(out.startsWith("_✓ ")).toBe(true);
616
- expect(out.endsWith("_")).toBe(true);
635
+ expect(out.startsWith("~~_✓ ")).toBe(true);
636
+ expect(out.endsWith("_~~")).toBe(true);
617
637
  });
618
638
 
619
639
  it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid markdown", () => {
@@ -770,7 +790,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
770
790
  expect(out).toContain("🤖 **Agent**");
771
791
  expect(out).toContain("_15s · 7 tools_");
772
792
  // Step feed follows the header.
773
- expect(out).toContain("_✓ Reading CLAUDE.md_");
793
+ expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
774
794
  expect(out).toContain("**→ Searching memory**");
775
795
  });
776
796
 
@@ -784,7 +804,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
784
804
  const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
785
805
  expect(out).toContain("🤖 **Agent**");
786
806
  expect(out).toContain("_done · 3 tools · 1m05s_");
787
- expect(out).toContain("_✓ Reading CLAUDE.md_");
807
+ expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
788
808
  // No in-progress arrow (final=true).
789
809
  expect(out).not.toContain("→");
790
810
  });
@@ -824,7 +844,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
824
844
  expect(out).toContain("🤖 **Agent**");
825
845
  expect(out).toContain("_30s · 5 tools_");
826
846
  // Parent step is done-styled (child is the live step).
827
- expect(out).toContain("_✓ Delegating: review_");
847
+ expect(out).toContain("~~_✓ Delegating: review_~~");
828
848
  // Child step is the in-progress step.
829
849
  expect(out).toContain(" ↳ **→ Reading schema.ts**");
830
850
  });
@@ -847,3 +867,41 @@ describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
847
867
  expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
848
868
  });
849
869
  });
870
+
871
+ describe("status-card body stacks like a reply (#2669 rich-message renderer)", () => {
872
+ // The single most-seen surface: the operator's progress card. Its step lines
873
+ // are styled prose (`~~_✓ …_~~`, `**→ …**`), NOT GFM list items, so a lone `\n`
874
+ // between them is a SOFT break the renderer collapses — bullets ran together
875
+ // into one line. The card now joins via stackCardLines (hard breaks), so the
876
+ // bullets stack exactly as the reply path renders them.
877
+
878
+ it("multiple step bullets stack on their own lines (no soft-break collapse)", () => {
879
+ const out = renderActivityFeed(["Reading a.ts", "Searching memory", "Running tests"], true)!;
880
+ const lines = out.split(" \n");
881
+ expect(lines).toEqual(["~~_✓ Reading a.ts_~~", "~~_✓ Searching memory_~~", "~~_✓ Running tests_~~"]);
882
+ // Not a single lone `\n` survives to collapse two bullets.
883
+ expect(out).not.toMatch(/(?<! {2})\n/);
884
+ });
885
+
886
+ it("the collapsed-inline-bullet case is fixed on a card body specifically", () => {
887
+ // Three labels that, joined by a lone `\n`, would render as a single run-on
888
+ // visual line in the rich renderer. After stacking, each renders distinctly.
889
+ const out = renderActivityFeed(["Editing foo.ts", "Editing bar.ts", "Writing baz.ts"])!;
890
+ // Every adjacent pair is separated by a hard break.
891
+ expect(out.match(/ {2}\n/g)?.length).toBe(2);
892
+ // The newest is the in-progress bold step; earlier are done — all visible.
893
+ expect(out).toContain("~~_✓ Editing foo.ts_~~");
894
+ expect(out).toContain("~~_✓ Editing bar.ts_~~");
895
+ expect(out).toContain("**→ Writing baz.ts**");
896
+ });
897
+
898
+ it("stacking does not corrupt the card's own italic/bold wrapper markup", () => {
899
+ // The status card wraps each step in `_…_` (done) or `**→ …**` (live). The
900
+ // stacking pass only changes the SEPARATOR between lines — the per-line
901
+ // wrapper markup must be byte-identical. (Step TEXT is stripped to plain
902
+ // prose by escapeStepLine; the inline-markup-survives case is covered by
903
+ // the stackCardLines unit test, which operates on pre-rendered lines.)
904
+ const out = renderActivityFeed(["Reading a.ts", "Running tests"], false)!;
905
+ expect(out).toBe("~~_✓ Reading a.ts_~~ \n**→ Running tests**");
906
+ });
907
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { maybeRotate, TURNS_JSONL_MAX_BYTES, type RotateFs } from '../gateway/turns-jsonl-rotate.js'
4
+
5
+ describe('maybeRotate — turns.jsonl size cap', () => {
6
+ const mkFs = (size: number | undefined) => {
7
+ const rename = vi.fn()
8
+ const fs: RotateFs = { statSize: () => size, rename }
9
+ return { fs, rename }
10
+ }
11
+
12
+ it('does not rotate below the cap', () => {
13
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES - 1)
14
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
15
+ expect(rename).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('rotates at/over the cap → renames to <path>.1', () => {
19
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES)
20
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(true)
21
+ expect(rename).toHaveBeenCalledWith('/state/agent/turns.jsonl', '/state/agent/turns.jsonl.1')
22
+ })
23
+
24
+ it('absent file (statSize undefined) → no rotation', () => {
25
+ const { fs, rename } = mkFs(undefined)
26
+ expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
27
+ expect(rename).not.toHaveBeenCalled()
28
+ })
29
+
30
+ it('rotation is bounded to one generation (overwrites <path>.1)', () => {
31
+ // A second rotation renames onto the same .1 target — no unbounded
32
+ // accumulation of generations.
33
+ const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES * 3)
34
+ maybeRotate('/a/turns.jsonl', fs)
35
+ maybeRotate('/a/turns.jsonl', fs)
36
+ expect(rename).toHaveBeenNthCalledWith(1, '/a/turns.jsonl', '/a/turns.jsonl.1')
37
+ expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
38
+ })
39
+ })
@@ -84,6 +84,18 @@ describe('classifyRejection — benign Telegram 400s', () => {
84
84
  const err = grammyError(400, 'Bad Request: MESSAGE IS NOT MODIFIED: blah')
85
85
  expect(classifyRejection(err)).toBe('log_only')
86
86
  })
87
+
88
+ it('returns "log_only" for pin-rights 400 "not enough rights to manage pinned messages" (marko 2026-07-01)', () => {
89
+ // The bot is not an admin in a supergroup, so the auto status-pin
90
+ // driver's pinChatMessage returns this 400. It was crashing the whole
91
+ // gateway via a leaked fire-and-forget rejection. Auto-pin is cosmetic —
92
+ // a missing pin right must never be fatal.
93
+ const err = grammyError(
94
+ 400,
95
+ 'Bad Request: not enough rights to manage pinned messages in the chat',
96
+ )
97
+ expect(classifyRejection(err)).toBe('log_only')
98
+ })
87
99
  })
88
100
 
89
101
  describe('classifyRejection — genuine errors still crash', () => {