switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,366 @@
1
+ /**
2
+ * #4065 follow-up — the SECOND inertness layer: the retry policy's benign-400
3
+ * swallow.
4
+ *
5
+ * Why this file exists
6
+ * --------------------
7
+ * `rollout-status-edit.test.ts` drives `handleRolloutStatusEdit` with an
8
+ * injected `editMessage` that throws a PLAIN `Error`, so `robustApiCall` is
9
+ * never in the loop. Production wires that same `editMessage` through
10
+ * `robustApiCall` (gateway.ts, `onRolloutStatusEdit`), and the retry policy
11
+ * classifies `400 "message to edit not found"` as a BENIGN 400
12
+ * (`classifyBenignTelegram400` → `'message_not_found'`) and RESOLVES
13
+ * `undefined` instead of rethrowing.
14
+ *
15
+ * The handler reads success from the absence of a throw. So on the real path,
16
+ * for the exact scenario the feature was written for — a seeded-resume
17
+ * narrator holding a carried `narration_message_id` whose card the operator
18
+ * deleted — the handler replied `{ok:true}`, hostd recorded a frozen card as
19
+ * live, and no re-post ever happened. Worse than pre-fix: hostd positively
20
+ * believed the card was fine. The `gone` signal was unreachable for its
21
+ * primary trigger while every test for it was green.
22
+ *
23
+ * So this suite refuses the fake seam twice over:
24
+ *
25
+ * - the edit goes through a `robustApiCall` composed EXACTLY as gateway.ts
26
+ * composes it (real `createSendGate.gate` wrapping the real
27
+ * `createRetryApiCall`), with a REAL `GrammyError` carrying Telegram's
28
+ * real `description`;
29
+ * - and the outcome is asserted where the operator feels it — the REAL
30
+ * `LogTailRolloutNarrator`, which must answer a deleted card with exactly
31
+ * one fresh post.
32
+ *
33
+ * A test that only asserted `{ok:false, gone:true}` would not have failed on
34
+ * the original #4065 bug either, so this one asserts the re-post.
35
+ */
36
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
37
+ import { GrammyError } from 'grammy'
38
+ import { createRetryApiCall, type RetryCallOpts } from '../retry-api-call.js'
39
+ import { createSendGate } from '../send-gate.js'
40
+ import { handleRolloutStatusEdit } from '../gateway/rollout-status-edit.js'
41
+ import type { RolloutStatusEditMessage } from '../gateway/ipc-protocol.js'
42
+ import {
43
+ LogTailRolloutNarrator,
44
+ type RolloutCardEscalation,
45
+ type RolloutEditOutcome,
46
+ type RolloutNarrationRelay,
47
+ } from '../../src/host-control/rollout-narrator.js'
48
+ import type { StatusEntry } from '../../src/host-control/server.js'
49
+ import type { RolloutPhase } from '../../src/cli/rollout.js'
50
+
51
+ /** A real grammy 400, byte-identical in shape to what Bot API returns. */
52
+ function telegram400(description: string): GrammyError {
53
+ return new GrammyError(
54
+ "Call to 'editMessageText' failed!",
55
+ {
56
+ ok: false,
57
+ error_code: 400,
58
+ description: `Bad Request: ${description}`,
59
+ } as never,
60
+ 'editMessageText',
61
+ {} as never,
62
+ )
63
+ }
64
+
65
+ /**
66
+ * `robustApiCall` composed exactly as gateway.ts:~5740 composes it:
67
+ * `sendGate.gate(() => rawRetry(fn, opts), opts)`. No stubs on either layer —
68
+ * this is the production sandwich, and the bug lived between its two slices.
69
+ */
70
+ function makeProductionRobustApiCall(): <T>(
71
+ fn: () => Promise<T>,
72
+ opts?: RetryCallOpts,
73
+ ) => Promise<T> {
74
+ const rawRetry = createRetryApiCall({
75
+ // Only the sleep is injected (fake timers would otherwise stall the
76
+ // network-retry backoff); every classification branch is the real one.
77
+ sleep: async () => {},
78
+ })
79
+ const sendGate = createSendGate({ initialWindows: [], bootRamp: {} })
80
+ return <T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T> =>
81
+ sendGate.gate(() => rawRetry(fn, opts), opts) as Promise<T>
82
+ }
83
+
84
+ /**
85
+ * The gateway's `onRolloutStatusEdit` deps, reproduced with the SAME
86
+ * `robustApiCall` opts the production call site passes — including
87
+ * `rethrowBenign400: true`, the fix under test.
88
+ */
89
+ function editThroughGateway(
90
+ fail: (() => never) | null,
91
+ seen: { edits: number },
92
+ ): (chatId: string | number, messageId: number, text: string) => Promise<unknown> {
93
+ const robustApiCall = makeProductionRobustApiCall()
94
+ return (chatId, messageId, _text) =>
95
+ robustApiCall(
96
+ async () => {
97
+ seen.edits++
98
+ if (fail) fail()
99
+ return { message_id: messageId }
100
+ },
101
+ {
102
+ chat_id: String(chatId),
103
+ verb: 'rollout-status-edit',
104
+ priorityClass: 'critical',
105
+ rethrowBenign400: true,
106
+ },
107
+ )
108
+ }
109
+
110
+ /** Run one `rollout_status_edit` end-to-end and return the reply hostd sees. */
111
+ async function relayOneEdit(
112
+ fail: (() => never) | null,
113
+ messageId = 4242,
114
+ ): Promise<{ reply: Record<string, unknown> | undefined; edits: number }> {
115
+ const seen = { edits: 0 }
116
+ const sent: Record<string, unknown>[] = []
117
+ await handleRolloutStatusEdit(
118
+ {
119
+ selfAgentName: 'overlord',
120
+ operatorChatId: () => 12345,
121
+ editMessage: editThroughGateway(fail, seen),
122
+ log: () => {},
123
+ },
124
+ { send: (m) => void sent.push(m as unknown as Record<string, unknown>) },
125
+ {
126
+ type: 'rollout_status_edit',
127
+ requestId: 'ro-1',
128
+ agentName: 'overlord',
129
+ messageId,
130
+ text: 'phase: self-bump-done',
131
+ } as RolloutStatusEditMessage,
132
+ )
133
+ return { reply: sent[0], edits: seen.edits }
134
+ }
135
+
136
+ describe('rollout-status-edit through the REAL retry policy', () => {
137
+ it('a DELETED card surfaces gone:true (the benign-400 swallow is opted out of)', async () => {
138
+ // Before the fix this resolved `{ok:true}` — the swallow ate the 400 and
139
+ // the handler saw no throw. That is the whole bug.
140
+ const { reply, edits } = await relayOneEdit(() => {
141
+ throw telegram400('message to edit not found')
142
+ })
143
+ expect(edits).toBe(1) // not retried — a benign 400 is terminal
144
+ expect(reply).toMatchObject({
145
+ type: 'rollout_status_edited',
146
+ requestId: 'ro-1',
147
+ ok: false,
148
+ gone: true,
149
+ })
150
+ })
151
+
152
+ it('"message is not modified" is a SUCCESS, not a gone (the card is live and correct)', async () => {
153
+ // Also swallowed by default; rethrowing it puts ONE classifier in charge
154
+ // rather than leaving the right answer to a coincidence.
155
+ const { reply } = await relayOneEdit(() => {
156
+ throw telegram400('message is not modified')
157
+ })
158
+ expect(reply).toMatchObject({ type: 'rollout_status_edited', ok: true })
159
+ expect(reply).not.toHaveProperty('gone')
160
+ })
161
+
162
+ it("\"message can't be edited\" is also gone", async () => {
163
+ const { reply } = await relayOneEdit(() => {
164
+ throw telegram400("message can't be edited")
165
+ })
166
+ expect(reply).toMatchObject({ ok: false, gone: true })
167
+ })
168
+
169
+ it('a NON-benign rejection stays transient (no duplicate card)', async () => {
170
+ const { reply } = await relayOneEdit(() => {
171
+ throw telegram400('chat not found')
172
+ })
173
+ expect(reply).toMatchObject({ ok: false, gone: false })
174
+ })
175
+
176
+ it('an applied edit still replies ok:true', async () => {
177
+ const { reply, edits } = await relayOneEdit(null)
178
+ expect(edits).toBe(1)
179
+ expect(reply).toEqual({ type: 'rollout_status_edited', requestId: 'ro-1', ok: true })
180
+ })
181
+ })
182
+
183
+ describe('rethrowBenign400 is scoped — other callers keep the swallow', () => {
184
+ it('a call WITHOUT the opt-out still resolves undefined on a benign 400', async () => {
185
+ const robustApiCall = makeProductionRobustApiCall()
186
+ const res = await robustApiCall(
187
+ async () => {
188
+ throw telegram400('message to edit not found')
189
+ },
190
+ { chat_id: '12345', verb: 'some-card-repaint' },
191
+ )
192
+ expect(res).toBeUndefined()
193
+ })
194
+
195
+ it('the benign observer still fires when the error is rethrown', async () => {
196
+ const kinds: string[] = []
197
+ const retry = createRetryApiCall({
198
+ sleep: async () => {},
199
+ observer: { onBenign: ({ kind }) => void kinds.push(kind) },
200
+ })
201
+ await expect(
202
+ retry(
203
+ async () => {
204
+ throw telegram400('message to edit not found')
205
+ },
206
+ { rethrowBenign400: true },
207
+ ),
208
+ ).rejects.toBeInstanceOf(GrammyError)
209
+ expect(kinds).toEqual(['message_not_found'])
210
+ })
211
+ })
212
+
213
+ // ── The operator-visible outcome: a deleted card gets a fresh one ────────────
214
+ //
215
+ // The reply shape above is a means, not the end. What the operator feels is
216
+ // whether a roll whose card was deleted still ends on a truthful ✅/❌. So the
217
+ // last test drives the REAL narrator whose relay is the REAL handler over the
218
+ // REAL retry policy, and asserts the re-post.
219
+
220
+ function makeEntry(over: Partial<StatusEntry> = {}): StatusEntry {
221
+ return {
222
+ request_id: 'ro-1',
223
+ caller: { kind: 'agent', name: 'overlord' },
224
+ op: 'rollout',
225
+ result: 'started',
226
+ exit_code: null,
227
+ started_at: Date.now(),
228
+ finished_at: null,
229
+ stdout_tail: '',
230
+ stderr_tail: '',
231
+ pin: 'v1.2.3',
232
+ ...over,
233
+ }
234
+ }
235
+
236
+ const phase = (p: string, over: Partial<RolloutPhase> = {}): RolloutPhase =>
237
+ ({ phase: p as RolloutPhase['phase'], target: 'v1.2.3', ...over }) as RolloutPhase
238
+
239
+ /**
240
+ * Drive the narrator's debounce to quiescence.
241
+ *
242
+ * This file is swept by BOTH runners (vitest and the `bun test` sweep over
243
+ * telegram-plugin/), so it must stay on the dual-run-safe timer API: SYNC
244
+ * `advanceTimersByTime` + a microtask flush, never `vi.runAllTimersAsync`
245
+ * (undefined under bun — same trap as tests/draft-stream.test.ts). Looped,
246
+ * because a debounced flush awaits an async relay round-trip that can arm the
247
+ * next timer.
248
+ */
249
+ async function drainNarrator(): Promise<void> {
250
+ for (let i = 0; i < 12; i++) {
251
+ vi.advanceTimersByTime(2000)
252
+ for (let j = 0; j < 12; j++) await Promise.resolve()
253
+ }
254
+ }
255
+
256
+ /**
257
+ * A narration relay whose `edit()` runs the REAL gateway handler over the REAL
258
+ * retry policy and maps the reply exactly as `SocketRolloutNarrationRelay`
259
+ * does (`ok===true` → `{ok:true}`, else `{ok:false, gone: obj.gone===true}`).
260
+ * The socket transport between them is pinned separately by
261
+ * `rollout-narration-edit-socket.test.ts`; what is under test here is the
262
+ * classification, which has to happen where the error still exists.
263
+ */
264
+ function makeRealPathRelay(
265
+ deadMessageIds: Set<number>,
266
+ nextMessageId: number | null,
267
+ ): RolloutNarrationRelay & { posts: number[]; edits: { messageId: number; text: string }[] } {
268
+ const posts: number[] = []
269
+ const edits: { messageId: number; text: string }[] = []
270
+ return {
271
+ posts,
272
+ edits,
273
+ async post() {
274
+ posts.push(nextMessageId ?? -1)
275
+ return nextMessageId
276
+ },
277
+ async edit(args): Promise<RolloutEditOutcome> {
278
+ edits.push({ messageId: args.messageId, text: args.text })
279
+ const dead = deadMessageIds.has(args.messageId)
280
+ const seen = { edits: 0 }
281
+ const sent: Record<string, unknown>[] = []
282
+ await handleRolloutStatusEdit(
283
+ {
284
+ selfAgentName: args.agentName,
285
+ operatorChatId: () => 12345,
286
+ editMessage: editThroughGateway(
287
+ dead
288
+ ? () => {
289
+ throw telegram400('message to edit not found')
290
+ }
291
+ : null,
292
+ seen,
293
+ ),
294
+ log: () => {},
295
+ },
296
+ { send: (m) => void sent.push(m as unknown as Record<string, unknown>) },
297
+ {
298
+ type: 'rollout_status_edit',
299
+ requestId: args.requestId,
300
+ agentName: args.agentName,
301
+ messageId: args.messageId,
302
+ text: args.text,
303
+ } as RolloutStatusEditMessage,
304
+ )
305
+ const reply = sent[0]
306
+ if (reply?.ok === true) return { ok: true }
307
+ return {
308
+ ok: false,
309
+ gone: reply?.gone === true,
310
+ ...(typeof reply?.reason === 'string' ? { reason: reply.reason } : {}),
311
+ }
312
+ },
313
+ }
314
+ }
315
+
316
+ describe('a deleted rollout card produces a re-post, driven through the real retry policy', () => {
317
+ beforeEach(() => void vi.useFakeTimers())
318
+ afterEach(() => void vi.useRealTimers())
319
+
320
+ it('seeded narrator + operator-deleted card → exactly one fresh post, terminal lands on it', async () => {
321
+ // Card 4242 was carried across the self-bump and then deleted by the
322
+ // operator. 5555 (the re-post) is editable.
323
+ const relay = makeRealPathRelay(new Set([4242]), 5555)
324
+ const escalations: RolloutCardEscalation[] = []
325
+ const n = new LogTailRolloutNarrator(relay, {
326
+ debounceMs: 1000,
327
+ escalate: (e) => void escalations.push(e),
328
+ })
329
+ const entry = makeEntry()
330
+
331
+ n.seedPostedMessage('ro-1', 'overlord', 4242)
332
+ n.onPhase(entry, phase('self-bump-done'))
333
+ await drainNarrator()
334
+
335
+ // THE ASSERTION THIS WHOLE FILE EXISTS FOR. With the benign-400 swallow in
336
+ // place the handler replied ok:true, the narrator saw a healthy edit, and
337
+ // `posts` stayed empty forever.
338
+ expect(relay.posts).toEqual([5555])
339
+
340
+ n.onTerminal(makeEntry({ result: 'completed', rolled: ['a', 'b'] }))
341
+ await drainNarrator()
342
+
343
+ // Exactly one re-post (no storm), and the operator's live card carries the
344
+ // terminal outcome.
345
+ expect(relay.posts).toEqual([5555])
346
+ expect(relay.edits.at(-1)!.messageId).toBe(5555)
347
+ expect(relay.edits.at(-1)!.text).toContain('✅')
348
+ // A restored card is not a failure.
349
+ expect(escalations).toEqual([])
350
+ })
351
+
352
+ it('a live card is never re-posted (no duplicate on the happy path)', async () => {
353
+ const relay = makeRealPathRelay(new Set(), 5555)
354
+ const n = new LogTailRolloutNarrator(relay, { debounceMs: 1000 })
355
+ const entry = makeEntry()
356
+
357
+ n.seedPostedMessage('ro-1', 'overlord', 4242)
358
+ n.onPhase(entry, phase('self-bump-done'))
359
+ await drainNarrator()
360
+ n.onTerminal(makeEntry({ result: 'completed', rolled: ['a'] }))
361
+ await drainNarrator()
362
+
363
+ expect(relay.posts).toEqual([])
364
+ expect(relay.edits.at(-1)!.messageId).toBe(4242)
365
+ })
366
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * #4065 — the rollout card's edit relay must tell hostd the TRUTH about an
3
+ * edit, so a seeded-resume narrator can notice it is editing a card that no
4
+ * longer exists instead of leaving the operator with a frozen card.
5
+ *
6
+ * Outcome under test (gateway half): "message to edit not found" replies
7
+ * `gone:true` (the only class that may trigger a re-post), everything else
8
+ * replies `gone:false`, and "message is not modified" is a SUCCESS — the live
9
+ * card already carries that exact body, so re-posting it would duplicate a
10
+ * perfectly good card.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ classifyRolloutEditError,
16
+ handleRolloutStatusEdit,
17
+ type RolloutStatusEditDeps,
18
+ } from '../gateway/rollout-status-edit.js'
19
+ import type {
20
+ RolloutStatusEditMessage,
21
+ RolloutStatusEditedEvent,
22
+ } from '../gateway/ipc-protocol.js'
23
+
24
+ const MSG: RolloutStatusEditMessage = {
25
+ type: 'rollout_status_edit',
26
+ requestId: 'ro-1',
27
+ agentName: 'overlord',
28
+ messageId: 4242,
29
+ text: 'rolling…',
30
+ }
31
+
32
+ function harness(
33
+ over: Partial<RolloutStatusEditDeps> = {},
34
+ ): { deps: RolloutStatusEditDeps; sent: RolloutStatusEditedEvent[]; client: { send(m: RolloutStatusEditedEvent): void } } {
35
+ const sent: RolloutStatusEditedEvent[] = []
36
+ const deps: RolloutStatusEditDeps = {
37
+ selfAgentName: 'overlord',
38
+ operatorChatId: () => 12345,
39
+ editMessage: async () => ({ message_id: 4242 }),
40
+ log: () => {},
41
+ ...over,
42
+ }
43
+ return { deps, sent, client: { send: (m) => sent.push(m) } }
44
+ }
45
+
46
+ /** A grammy-shaped API error (the real thing carries `description`). */
47
+ function tgError(description: string): Error & { description: string } {
48
+ const e = new Error(description) as Error & { description: string }
49
+ e.description = description
50
+ return e
51
+ }
52
+
53
+ describe('classifyRolloutEditError', () => {
54
+ it('classifies a deleted / uneditable target as gone', () => {
55
+ expect(
56
+ classifyRolloutEditError(tgError('Bad Request: message to edit not found')),
57
+ ).toBe('gone')
58
+ expect(
59
+ classifyRolloutEditError(tgError("Bad Request: message can't be edited")),
60
+ ).toBe('gone')
61
+ expect(classifyRolloutEditError(tgError('Bad Request: MESSAGE_ID_INVALID'))).toBe(
62
+ 'gone',
63
+ )
64
+ })
65
+
66
+ it('classifies an unchanged body as not-modified (the card is live)', () => {
67
+ expect(
68
+ classifyRolloutEditError(
69
+ tgError('Bad Request: message is not modified: specified new message content...'),
70
+ ),
71
+ ).toBe('not-modified')
72
+ })
73
+
74
+ it('classifies everything else as transient — a re-post would duplicate a live card', () => {
75
+ expect(classifyRolloutEditError(tgError('Too Many Requests: retry after 12'))).toBe(
76
+ 'transient',
77
+ )
78
+ expect(classifyRolloutEditError(new Error('socket hang up'))).toBe('transient')
79
+ expect(classifyRolloutEditError(undefined)).toBe('transient')
80
+ })
81
+ })
82
+
83
+ describe('handleRolloutStatusEdit', () => {
84
+ it('replies ok on a successful edit', async () => {
85
+ const h = harness()
86
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
87
+ expect(h.sent).toEqual([{ type: 'rollout_status_edited', requestId: 'ro-1', ok: true }])
88
+ })
89
+
90
+ it('replies gone:true when the card no longer exists (the #4065 signal)', async () => {
91
+ const h = harness({
92
+ editMessage: async () => {
93
+ throw tgError('Bad Request: message to edit not found')
94
+ },
95
+ })
96
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
97
+ expect(h.sent).toHaveLength(1)
98
+ expect(h.sent[0]).toMatchObject({ requestId: 'ro-1', ok: false, gone: true })
99
+ })
100
+
101
+ it('replies gone:false on a transient failure', async () => {
102
+ const h = harness({
103
+ editMessage: async () => {
104
+ throw tgError('Too Many Requests: retry after 12')
105
+ },
106
+ })
107
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
108
+ expect(h.sent[0]).toMatchObject({ ok: false, gone: false })
109
+ })
110
+
111
+ it('treats "not modified" as success — the live card already says this', async () => {
112
+ const h = harness({
113
+ editMessage: async () => {
114
+ throw tgError('Bad Request: message is not modified')
115
+ },
116
+ })
117
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
118
+ expect(h.sent).toEqual([{ type: 'rollout_status_edited', requestId: 'ro-1', ok: true }])
119
+ })
120
+
121
+ it('rejects an agent mismatch without editing, and never reports gone', async () => {
122
+ let edits = 0
123
+ const h = harness({
124
+ selfAgentName: 'someone-else',
125
+ editMessage: async () => {
126
+ edits++
127
+ return {}
128
+ },
129
+ })
130
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
131
+ expect(edits).toBe(0)
132
+ expect(h.sent[0]).toMatchObject({ ok: false, gone: false, reason: 'agent mismatch' })
133
+ })
134
+
135
+ it('replies (never throws) when there is no operator chat', async () => {
136
+ const h = harness({ operatorChatId: () => undefined })
137
+ await handleRolloutStatusEdit(h.deps, h.client, MSG)
138
+ expect(h.sent[0]).toMatchObject({ ok: false, gone: false, reason: 'no operator chat' })
139
+ })
140
+ })
@@ -10,7 +10,9 @@
10
10
  * OPERATOR chat (allowFrom[0]) as an ORDINARY message (no pin, no
11
11
  * inline_keyboard card), and replies with the message_id.
12
12
  * 3. the EDIT handler edits in place via the wrapped retry path
13
- * (swallowingApiCall) — fire-and-forget, never a raw bot.api call.
13
+ * (robustApiCall) — never a raw bot.api call — and, since #4065, replies
14
+ * with the outcome so hostd can tell an applied edit from an edit into a
15
+ * deleted card. Its body lives in gateway/rollout-status-edit.ts.
14
16
  * 4. NEITHER handler pins a message — this is in-chat narration, not the
15
17
  * retired pinned card (chat-is-the-single-source-of-truth).
16
18
  */
@@ -19,6 +21,10 @@ import { readFileSync } from "node:fs";
19
21
 
20
22
  const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
21
23
  const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
24
+ const editModule = readFileSync(
25
+ new URL("../gateway/rollout-status-edit.ts", import.meta.url),
26
+ "utf8",
27
+ );
22
28
 
23
29
  describe("rollout_status_* — ipc-server routing", () => {
24
30
  it("validates + dispatches rollout_status_post", () => {
@@ -61,21 +67,48 @@ describe("onRolloutStatusPost — gateway handler invariants", () => {
61
67
  });
62
68
 
63
69
  describe("onRolloutStatusEdit — gateway handler invariants", () => {
64
- const start = gw.indexOf("onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage)");
70
+ // #4065 moved the handler BODY into gateway/rollout-status-edit.ts (which has
71
+ // its own behavioural suite, rollout-status-edit.test.ts). gateway.ts keeps
72
+ // only the wiring, so the invariants are pinned across both files.
73
+ const start = gw.indexOf("onRolloutStatusEdit(client: IpcClient, msg: RolloutStatusEditMessage)");
65
74
  const next = gw.indexOf("onInjectInbound(", start);
66
75
  const handler = gw.slice(start, next > start ? next : start + 2000);
67
76
 
68
77
  it("the handler exists", () => {
69
78
  expect(start).toBeGreaterThan(0);
70
79
  });
71
- it("fences the agent", () => {
72
- expect(handler).toMatch(/msg\.agentName !== self/);
80
+ it("delegates to the extracted edit handler", () => {
81
+ expect(handler).toMatch(/handleRolloutStatusEdit\(/);
82
+ expect(gw).toMatch(
83
+ /import \{ handleRolloutStatusEdit \} from '\.\/rollout-status-edit\.js'/,
84
+ );
85
+ });
86
+ it("fences the agent (agentName must match this gateway's own)", () => {
87
+ expect(handler).toMatch(/selfAgentName: process\.env\.SWITCHROOM_AGENT_NAME/);
88
+ expect(editModule).toMatch(/msg\.agentName !== self/);
89
+ });
90
+ it("edits the OPERATOR chat in place via the wrapped retry path", () => {
91
+ expect(handler).toMatch(/loadAccess\(\)\.allowFrom\[0\]/);
92
+ expect(handler).toMatch(/robustApiCall\(/);
93
+ expect(handler).toMatch(/bot\.api\.editMessageText\(chatId, messageId/);
94
+ });
95
+ // Routing through robustApiCall is necessary but NOT sufficient, and pinning
96
+ // only that enshrines the bug it hides: the retry policy's benign-400 swallow
97
+ // resolves `400 "message to edit not found"` instead of throwing, and this
98
+ // handler reads success from the absence of a throw. Both opt-outs are what
99
+ // make the wrapped path safe here, so both are pinned. The BEHAVIOUR lives in
100
+ // tests/rollout-status-edit-retry-policy.test.ts (real retry policy, real
101
+ // GrammyError, asserts the re-post); this is the structural backstop.
102
+ it("opts out of BOTH inertness layers — send-gate shed and benign-400 swallow", () => {
103
+ expect(handler).toMatch(/priorityClass: 'critical'/);
104
+ expect(handler).toMatch(/rethrowBenign400: true/);
73
105
  });
74
- it("edits in place via the wrapped retry path (fire-and-forget)", () => {
75
- expect(handler).toMatch(/swallowingApiCall\(/);
76
- expect(handler).toMatch(/bot\.api\.editMessageText\(operator, msg\.messageId/);
106
+ it("reports the edit outcome back to hostd (#4065 — no more edit-into-void)", () => {
107
+ expect(editModule).toMatch(/rollout_status_edited/);
108
+ expect(editModule).toMatch(/gone: cls === 'gone'/);
77
109
  });
78
110
  it("does NOT pin (edits an ordinary message)", () => {
79
111
  expect(handler).not.toMatch(/pinChatMessage/);
112
+ expect(editModule).not.toMatch(/pinChatMessage/);
80
113
  });
81
114
  });
@@ -77,6 +77,10 @@ afterEach(() => {
77
77
  })
78
78
 
79
79
  describe('tg-post logger — resolved ok:false responses (#3927)', () => {
80
+ // These calls are made directly on `bot.api.*` with NO enclosing retry
81
+ // policy, so every rejection here IS the outcome and stays `status=err`.
82
+ // The `status=retry` tier (#3931) only applies inside `createRetryApiCall`
83
+ // — see `tg-post-retry-attribution.test.ts`.
80
84
  it('logs a 429 flood-wait as status=err code=429 (FAILS before the fix: logged status=ok)', async () => {
81
85
  const lines = await tgPostLinesFor({
82
86
  ok: false,