switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,450 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ loadStatusPins,
4
+ persistStatusPins,
5
+ pinnedMessageIsOurs,
6
+ reconcileAndPersistStatusPin,
7
+ runStatusPinBootCleanup,
8
+ type PersistedStatusPin,
9
+ type StatusPinStoreFsSeam,
10
+ type TrackedStatusPin,
11
+ } from "../gateway/status-pin-store.js";
12
+
13
+ /** In-memory fs seam with an atomic rename, so the store's tmp→rename
14
+ * crash-safety contract is exercised without touching the real disk. */
15
+ function memFs(seed: Record<string, string> = {}) {
16
+ const files = new Map<string, string>(Object.entries(seed));
17
+ const calls: string[] = [];
18
+ const fs: StatusPinStoreFsSeam = {
19
+ readFileSync: (p) => {
20
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`);
21
+ return files.get(p)!;
22
+ },
23
+ writeFileSync: (p, d) => {
24
+ calls.push(`write:${p}`);
25
+ files.set(p, d);
26
+ },
27
+ renameSync: (a, b) => {
28
+ calls.push(`rename:${a}->${b}`);
29
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`);
30
+ files.set(b, files.get(a)!);
31
+ files.delete(a);
32
+ },
33
+ existsSync: (p) => files.has(p),
34
+ };
35
+ return { fs, files, calls };
36
+ }
37
+
38
+ const PATH = "/state/agent/telegram/status-pins.json";
39
+
40
+ function pin(over: Partial<PersistedStatusPin> = {}): PersistedStatusPin {
41
+ return { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, ...over };
42
+ }
43
+
44
+ describe("status-pin-store", () => {
45
+ it("round-trips the pin claim set", () => {
46
+ const { fs } = memFs();
47
+ const snap: PersistedStatusPin[] = [
48
+ pin({ pinKey: "fg:c:3", messageId: 715 }),
49
+ pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
50
+ ];
51
+ persistStatusPins(PATH, fs, snap);
52
+ expect(loadStatusPins(PATH, fs)).toEqual(snap);
53
+ });
54
+
55
+ it("writes via tmp + atomic rename (crash-safe)", () => {
56
+ const { fs, calls } = memFs();
57
+ persistStatusPins(PATH, fs, [pin()]);
58
+ expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`]);
59
+ });
60
+
61
+ it("persist-on-pin: a new claim is reflected on disk", () => {
62
+ const { fs } = memFs();
63
+ // Simulate reconcile writing state, then snapshotting.
64
+ persistStatusPins(PATH, fs, [pin({ pinKey: "fg:c:3", messageId: 715 })]);
65
+ const loaded = loadStatusPins(PATH, fs);
66
+ expect(loaded).toHaveLength(1);
67
+ expect(loaded[0].messageId).toBe(715);
68
+ });
69
+
70
+ it("remove-on-unpin: unpinning empties the persisted set", () => {
71
+ const { fs } = memFs();
72
+ persistStatusPins(PATH, fs, [pin()]);
73
+ expect(loadStatusPins(PATH, fs)).toHaveLength(1);
74
+ // Reconcile deleted the key → snapshot of the now-empty Map.
75
+ persistStatusPins(PATH, fs, []);
76
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
77
+ });
78
+
79
+ it("loads [] when no file exists (fresh boot)", () => {
80
+ const { fs } = memFs();
81
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
82
+ });
83
+
84
+ it("fail-open: corrupt JSON loads as [] (never crashes boot)", () => {
85
+ const { fs } = memFs({ [PATH]: "{not json" });
86
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
87
+ });
88
+
89
+ it("fail-open: wrong envelope version / shape loads as []", () => {
90
+ // v3 is an unknown FUTURE version (v1 + v2 are the supported set).
91
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
92
+ expect(loadStatusPins(PATH, f1)).toEqual([]);
93
+ const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
94
+ expect(loadStatusPins(PATH, f2)).toEqual([]);
95
+ });
96
+
97
+ it("drops malformed rows but keeps valid ones", () => {
98
+ const { fs } = memFs({
99
+ [PATH]: JSON.stringify({
100
+ v: 1,
101
+ pins: [
102
+ pin({ messageId: 715 }),
103
+ { pinKey: "", chatId: "c", messageId: 1 }, // empty pinKey → dropped
104
+ { pinKey: "k", chatId: "", messageId: 1 }, // empty chatId → dropped
105
+ { pinKey: "k", chatId: "c" }, // missing messageId → dropped
106
+ ],
107
+ }),
108
+ });
109
+ const loaded = loadStatusPins(PATH, fs);
110
+ expect(loaded).toHaveLength(1);
111
+ expect(loaded[0].messageId).toBe(715);
112
+ });
113
+
114
+ it("persist never throws on a failing fs (durability degrades, live path unaffected)", () => {
115
+ const throwingFs: StatusPinStoreFsSeam = {
116
+ readFileSync: () => {
117
+ throw new Error("boom");
118
+ },
119
+ writeFileSync: () => {
120
+ throw new Error("disk full");
121
+ },
122
+ renameSync: () => {
123
+ throw new Error("nope");
124
+ },
125
+ existsSync: () => false,
126
+ };
127
+ const logs: string[] = [];
128
+ expect(() =>
129
+ persistStatusPins(PATH, throwingFs, [pin()], (l) => logs.push(l)),
130
+ ).not.toThrow();
131
+ expect(logs.join("")).toContain("persist FAILED");
132
+ });
133
+ });
134
+
135
+ /**
136
+ * Chat-scoped ownership guard (BUG 1). message_ids are per-chat small integers
137
+ * and the gateway tracks many pins across chats/topics simultaneously — the
138
+ * guard MUST match on messageId AND chatId, never messageId alone.
139
+ */
140
+ describe("pinnedMessageIsOurs — chat-scoped guard", () => {
141
+ const tracked: TrackedStatusPin[] = [
142
+ { chatId: "-100AAA", messageId: 715 }, // chat A
143
+ { chatId: "-100BBB", messageId: 42 }, // chat B, different id
144
+ ];
145
+
146
+ it("matches a genuine same-chat + same-id pin", () => {
147
+ expect(pinnedMessageIsOurs(tracked, "-100AAA", 715)).toBe(true);
148
+ expect(pinnedMessageIsOurs(tracked, "-100BBB", 42)).toBe(true);
149
+ });
150
+
151
+ it("does NOT match when the id collides but the chat differs (the bug)", () => {
152
+ // Chat B receives a pinned_message update whose id (715) collides with
153
+ // chat A's tracked status pin. A messageId-only guard would wrongly return
154
+ // true and delete chat B's (possibly operator-manual) service message.
155
+ expect(pinnedMessageIsOurs(tracked, "-100BBB", 715)).toBe(false);
156
+ // Symmetric: chat A with chat B's id.
157
+ expect(pinnedMessageIsOurs(tracked, "-100AAA", 42)).toBe(false);
158
+ });
159
+
160
+ it("does NOT match an untracked id in a tracked chat", () => {
161
+ expect(pinnedMessageIsOurs(tracked, "-100AAA", 999)).toBe(false);
162
+ });
163
+
164
+ it("does NOT match against an empty tracked set", () => {
165
+ expect(pinnedMessageIsOurs([], "-100AAA", 715)).toBe(false);
166
+ });
167
+ });
168
+
169
+ /**
170
+ * Boot-orphan cleanup — calls the REAL `runStatusPinBootCleanup` (extracted
171
+ * pure over injected fs + unpin seams) so ordering / contract regressions are
172
+ * caught here, not just structurally.
173
+ */
174
+ describe("runStatusPinBootCleanup", () => {
175
+ it("unpins every persisted entry and empties the store", async () => {
176
+ const { fs } = memFs();
177
+ const orphans: PersistedStatusPin[] = [
178
+ pin({ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 }),
179
+ pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
180
+ ];
181
+ persistStatusPins(PATH, fs, orphans);
182
+
183
+ const unpinned: Array<[string, number]> = [];
184
+ const res = await runStatusPinBootCleanup({
185
+ path: PATH,
186
+ fs,
187
+ unpin: async (chatId, messageId) => {
188
+ unpinned.push([chatId, messageId]);
189
+ },
190
+ log: () => {},
191
+ });
192
+
193
+ expect(unpinned).toEqual([
194
+ ["-100123", 715],
195
+ ["-100999", 42],
196
+ ]);
197
+ expect(res).toEqual({ cleared: 2, total: 2 });
198
+ // Store empty afterwards → no re-attempt next boot.
199
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
200
+ });
201
+
202
+ it("a failing unpin is non-fatal and the store is still emptied", async () => {
203
+ const { fs } = memFs();
204
+ persistStatusPins(PATH, fs, [
205
+ pin({ pinKey: "fg:c:1", chatId: "-100123", messageId: 5 }),
206
+ pin({ pinKey: "fg:c:2", chatId: "-100123", messageId: 6 }),
207
+ ]);
208
+
209
+ const res = await runStatusPinBootCleanup({
210
+ path: PATH,
211
+ fs,
212
+ unpin: async (_c, messageId) => {
213
+ if (messageId === 5) throw new Error("chat gone");
214
+ },
215
+ log: () => {},
216
+ });
217
+
218
+ // One failed, one succeeded — still non-fatal, store still emptied.
219
+ expect(res).toEqual({ cleared: 1, total: 2 });
220
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
221
+ });
222
+
223
+ it("no-op on a fresh boot with no persisted pins (no unpin calls)", async () => {
224
+ const { fs } = memFs();
225
+ let calls = 0;
226
+ const res = await runStatusPinBootCleanup({
227
+ path: PATH,
228
+ fs,
229
+ unpin: async () => {
230
+ calls++;
231
+ },
232
+ log: () => {},
233
+ });
234
+ expect(res).toEqual({ cleared: 0, total: 0 });
235
+ expect(calls).toBe(0);
236
+ });
237
+
238
+ it("unpins a PENDING record too (crash in the persist-after-pin window)", async () => {
239
+ // Fix 1: a pin that landed in Telegram but whose confirming rewrite never
240
+ // ran leaves a `pending` record. Boot cleanup must treat it like a confirmed
241
+ // pin and unpin it — otherwise the orphan lingers forever.
242
+ const { fs } = memFs();
243
+ persistStatusPins(PATH, fs, [
244
+ pin({ pinKey: "fg:c:9", chatId: "-100777", messageId: 314, pending: true }),
245
+ ]);
246
+ const unpinned: Array<[string, number]> = [];
247
+ const res = await runStatusPinBootCleanup({
248
+ path: PATH,
249
+ fs,
250
+ unpin: async (chatId, messageId) => {
251
+ unpinned.push([chatId, messageId]);
252
+ },
253
+ log: () => {},
254
+ });
255
+ expect(unpinned).toEqual([["-100777", 314]]);
256
+ expect(res).toEqual({ cleared: 1, total: 1 });
257
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
258
+ });
259
+ });
260
+
261
+ /**
262
+ * Envelope versioning — a v1 snapshot (no `pending` field) must still load
263
+ * fail-open as a confirmed pin, and v2 must round-trip the `pending` flag.
264
+ */
265
+ describe("status-pin-store — envelope version compat", () => {
266
+ it("loads a legacy v1 snapshot (no pending field) as confirmed pins", () => {
267
+ const { fs } = memFs({
268
+ [PATH]: JSON.stringify({
269
+ v: 1,
270
+ pins: [{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 }],
271
+ }),
272
+ });
273
+ const loaded = loadStatusPins(PATH, fs);
274
+ expect(loaded).toHaveLength(1);
275
+ expect(loaded[0].pending).toBeUndefined();
276
+ expect(loaded[0].messageId).toBe(715);
277
+ });
278
+
279
+ it("round-trips a v2 pending flag and writes v2 envelopes", () => {
280
+ const { fs, files } = memFs();
281
+ persistStatusPins(PATH, fs, [pin({ pending: true })]);
282
+ expect(JSON.parse(files.get(PATH)!).v).toBe(2);
283
+ expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
284
+ });
285
+
286
+ it("rejects an unknown future envelope version (fail-open [])", () => {
287
+ const { fs } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
288
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
289
+ });
290
+
291
+ it("drops a row with a non-boolean pending field", () => {
292
+ const { fs } = memFs({
293
+ [PATH]: JSON.stringify({
294
+ v: 2,
295
+ pins: [
296
+ pin({ messageId: 715 }),
297
+ { pinKey: "k", chatId: "c", messageId: 1, pending: "yes" },
298
+ ],
299
+ }),
300
+ });
301
+ const loaded = loadStatusPins(PATH, fs);
302
+ expect(loaded).toHaveLength(1);
303
+ expect(loaded[0].messageId).toBe(715);
304
+ });
305
+ });
306
+
307
+ /**
308
+ * reconcileAndPersistStatusPin — the persist-BEFORE-pin ordering that closes the
309
+ * leak window (Fix 1). These exercise the REAL wrapper the gateway calls, with
310
+ * an fs seam that can crash at a chosen write so we can prove the leak is
311
+ * recovered (red→green against the pre-fix persist-after ordering).
312
+ */
313
+ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
314
+ it("writes a PENDING record BEFORE the pin API call", async () => {
315
+ const { fs } = memFs();
316
+ const order: string[] = [];
317
+ // Record what's on disk at the moment the pin API is invoked.
318
+ let onDiskAtPin: PersistedStatusPin[] = [];
319
+ const next = await reconcileAndPersistStatusPin({
320
+ path: PATH,
321
+ fs,
322
+ pinKey: "fg:c:3",
323
+ chatId: "-100123",
324
+ op: { kind: "pin", messageId: 715 },
325
+ snapshotOthers: () => [],
326
+ applyPin: async () => {
327
+ order.push("pin-api");
328
+ onDiskAtPin = loadStatusPins(PATH, fs);
329
+ return { messageId: 715 };
330
+ },
331
+ log: () => {},
332
+ });
333
+ // At the moment the pin API ran, a pending record was already on disk.
334
+ expect(onDiskAtPin).toEqual([
335
+ { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
336
+ ]);
337
+ // After success it's confirmed (pending cleared).
338
+ expect(next).toEqual({ messageId: 715 });
339
+ expect(loadStatusPins(PATH, fs)).toEqual([
340
+ { pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
341
+ ]);
342
+ });
343
+
344
+ it("RED→GREEN: a crash between pin-lands and confirm leaves a recoverable pending record", async () => {
345
+ // Simulate SIGKILL: the pin API lands in Telegram, then the process dies
346
+ // before the confirming rewrite. We model the crash by throwing out of
347
+ // applyPin AFTER it 'pinned' — the pending record must survive on disk.
348
+ const { fs } = memFs();
349
+ let pinnedInTelegram: number | null = null;
350
+ await expect(
351
+ reconcileAndPersistStatusPin({
352
+ path: PATH,
353
+ fs,
354
+ pinKey: "fg:c:3",
355
+ chatId: "-100123",
356
+ op: { kind: "pin", messageId: 715 },
357
+ snapshotOthers: () => [],
358
+ applyPin: async () => {
359
+ pinnedInTelegram = 715; // the pin landed in Telegram
360
+ throw new Error("SIGKILL — process died before confirm rewrite");
361
+ },
362
+ log: () => {},
363
+ }),
364
+ ).rejects.toThrow("SIGKILL");
365
+
366
+ // The pin is live in Telegram...
367
+ expect(pinnedInTelegram).toBe(715);
368
+ // ...AND a pending record survived on disk (this is what the pre-fix
369
+ // persist-AFTER-pin ordering FAILED to do — the store would have been empty
370
+ // and boot cleanup blind to the orphan).
371
+ const persisted = loadStatusPins(PATH, fs);
372
+ expect(persisted).toEqual([
373
+ { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
374
+ ]);
375
+
376
+ // Next boot: cleanup unpins the orphan using the pending record.
377
+ const unpinned: Array<[string, number]> = [];
378
+ await runStatusPinBootCleanup({
379
+ path: PATH,
380
+ fs,
381
+ unpin: async (c, m) => {
382
+ unpinned.push([c, m]);
383
+ },
384
+ log: () => {},
385
+ });
386
+ expect(unpinned).toEqual([["-100123", 715]]);
387
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
388
+ });
389
+
390
+ it("clears the pending record when the pin API fails (no phantom claim)", async () => {
391
+ const { fs } = memFs();
392
+ const next = await reconcileAndPersistStatusPin({
393
+ path: PATH,
394
+ fs,
395
+ pinKey: "fg:c:3",
396
+ chatId: "-100123",
397
+ op: { kind: "pin", messageId: 715 },
398
+ snapshotOthers: () => [],
399
+ // reconcilePin returns null (prevState) when the pin API throws.
400
+ applyPin: async () => null,
401
+ log: () => {},
402
+ });
403
+ expect(next).toBeNull();
404
+ // Pending record dropped — nothing to leak, nothing was pinned.
405
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
406
+ });
407
+
408
+ it("preserves OTHER live claims while flipping one key pending→confirmed", async () => {
409
+ const { fs } = memFs();
410
+ const other: PersistedStatusPin = {
411
+ pinKey: "wk:agent-x",
412
+ chatId: "-100999",
413
+ messageId: 42,
414
+ };
415
+ await reconcileAndPersistStatusPin({
416
+ path: PATH,
417
+ fs,
418
+ pinKey: "fg:c:3",
419
+ chatId: "-100123",
420
+ op: { kind: "pin", messageId: 715 },
421
+ snapshotOthers: () => [other],
422
+ applyPin: async () => ({ messageId: 715 }),
423
+ log: () => {},
424
+ });
425
+ const loaded = loadStatusPins(PATH, fs);
426
+ expect(loaded).toContainEqual(other);
427
+ expect(loaded).toContainEqual({
428
+ pinKey: "fg:c:3",
429
+ chatId: "-100123",
430
+ messageId: 715,
431
+ });
432
+ });
433
+
434
+ it("clear op: unpins then drops the record (post-pin ordering is safe)", async () => {
435
+ const { fs } = memFs();
436
+ persistStatusPins(PATH, fs, [pin({ pinKey: "fg:c:3", messageId: 715 })]);
437
+ const next = await reconcileAndPersistStatusPin({
438
+ path: PATH,
439
+ fs,
440
+ pinKey: "fg:c:3",
441
+ chatId: "-100123",
442
+ op: { kind: "clear" },
443
+ snapshotOthers: () => [],
444
+ applyPin: async () => null,
445
+ log: () => {},
446
+ });
447
+ expect(next).toBeNull();
448
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
449
+ });
450
+ });
@@ -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
+ })