switchroom 0.20.7 → 0.20.8

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.
@@ -260,3 +260,69 @@ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queu
260
260
  expect(rec.edits).toHaveLength(0)
261
261
  })
262
262
  })
263
+
264
+ describe('Part B — synthetic (fabricated) message ids never 400 the queued card', () => {
265
+ /** Recording bot that enforces the REAL Telegram Bot API contract on
266
+ * `reply_parameters.message_id`: anything non-integer or beyond signed int32
267
+ * is hard-rejected with the exact 400 the live gateway hit
268
+ * (gateway-supervisor.log 2026-08-04, msg=1785846295635) — BEFORE recording,
269
+ * exactly like the wire call. `allow_sending_without_reply` does not bypass
270
+ * the range check, only the message-not-found case. */
271
+ function withTelegramStrictBot(h: Harness) {
272
+ const sends: SendRec[] = []
273
+ let nextId = 9001
274
+ ;(h.deps as unknown as { bot: unknown }).bot = {
275
+ api: {
276
+ sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
277
+ const rp = opts.reply_parameters as { message_id?: unknown } | undefined
278
+ if (rp != null) {
279
+ const mid = rp.message_id
280
+ if (typeof mid !== 'number' || !Number.isInteger(mid) || mid <= 0 || mid >= 2 ** 31) {
281
+ throw new Error(
282
+ `Call to 'sendRichMessage' failed! (400: Bad Request: field "message_id" must be a valid Number)`,
283
+ )
284
+ }
285
+ }
286
+ const id = nextId++
287
+ sends.push({ chatId, markdown: msg.markdown, opts, id })
288
+ return { message_id: id }
289
+ },
290
+ editMessageText: async () => true,
291
+ deleteMessage: async () => true,
292
+ },
293
+ }
294
+ return { sends }
295
+ }
296
+
297
+ it('sends the queued card UNANCHORED (no 400) when the parked message id is a fabricated Date.now() timestamp', async () => {
298
+ const h = makeHarness()
299
+ const rec = withTelegramStrictBot(h)
300
+
301
+ // Turn A mints on an idle session.
302
+ handleSessionEvent(h.deps, enqueue('501'))
303
+ expect(rec.sends).toHaveLength(0)
304
+
305
+ // A synthetic enqueue (subagent handback / boot resume) parks mid-turn with
306
+ // a fabricated ms-timestamp message id — finite, but NOT a Telegram id.
307
+ handleSessionEvent(h.deps, enqueue('1785846295635', 'handback: worker done'))
308
+ await settle()
309
+ expect(__parkedTurnStartCountForTest()).toBe(1)
310
+
311
+ // The card SENT (no 400 — RED on the pre-fix guard, which forwarded the
312
+ // 13-digit id into reply_parameters and lost the whole card)…
313
+ expect(rec.sends).toHaveLength(1)
314
+ // …and it sent WITHOUT reply-linkage: no reply_parameters at all.
315
+ expect(rec.sends[0]!.opts.reply_parameters).toBeUndefined()
316
+ })
317
+
318
+ it('still reply-anchors when the parked message id is a real Telegram id', async () => {
319
+ const h = makeHarness()
320
+ const rec = withTelegramStrictBot(h)
321
+
322
+ handleSessionEvent(h.deps, enqueue('501'))
323
+ handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
324
+ await settle()
325
+ expect(rec.sends).toHaveLength(1)
326
+ expect((rec.sends[0]!.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
327
+ })
328
+ })
@@ -336,6 +336,51 @@ describe("makeSessionBusyDrainDeferral — F2 bounded busy-defer for the idle dr
336
336
  expect(off(true, 0)).toBe(false);
337
337
  expect(off(true, 10_000)).toBe(false);
338
338
  });
339
+
340
+ it("starts a FRESH bound for a new deferral episode after a call gap — a buffer emptied by bridge re-register (no busy=false call) must not pin a stale t0 (#4341 follow-up)", () => {
341
+ const BOUND = 20_000; // small bound so a realistic 5s poll cadence spans it
342
+ const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
343
+
344
+ // Episode A: a represent is buffered while the session is busy. The idle
345
+ // drain gate polls it every ~5s (< staleGap) and defers each time.
346
+ expect(defer(true, 0)).toBe(true); // t0 = 0
347
+ expect(defer(true, 5_000)).toBe(true);
348
+ expect(defer(true, 10_000)).toBe(true);
349
+
350
+ // The buffer is now emptied by a bridge re-register (onClientRegistered)
351
+ // while the session is STILL busy. That drain path does NOT consult this
352
+ // predicate, so there is no busy=false call — deferringSince stays at 0 on
353
+ // the buggy code. Time then advances well past the bound with the buffer
354
+ // empty (predicate not called).
355
+
356
+ // Episode B: a brand-new represent is buffered while the session is busy,
357
+ // long after t0. This is a NEW mid-answer session — the represent MUST stay
358
+ // deferred (its own bound has not elapsed), NOT be drained immediately.
359
+ // Buggy code: now - stalePinnedT0 (2_000_000 - 0) >= BOUND → false → the
360
+ // represent is drained into the mid-answer session, reopening the duplicate
361
+ // window. Fixed code: the >15s call gap starts a fresh episode clock at
362
+ // t=2_000_000, so it defers.
363
+ const B0 = 2_000_000;
364
+ expect(defer(true, B0)).toBe(true);
365
+ // ...and the fresh episode is still bounded from ITS OWN start, polled at the
366
+ // real ~5s cadence (each gap < staleGap, so no further reset).
367
+ expect(defer(true, B0 + 5_000)).toBe(true);
368
+ expect(defer(true, B0 + 10_000)).toBe(true);
369
+ expect(defer(true, B0 + 15_000)).toBe(true);
370
+ expect(defer(true, B0 + BOUND)).toBe(false); // fresh bound elapsed → drains
371
+ });
372
+
373
+ it("does NOT reset the clock on the normal poll cadence — a genuinely wedged busy session still drains at the bound (wedge budget preserved)", () => {
374
+ const BOUND = 20_000;
375
+ const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
376
+ // Polls arrive every 5s (< staleGap) throughout one continuous episode, so
377
+ // the clock is never reset and the bound elapses on schedule.
378
+ expect(defer(true, 0)).toBe(true);
379
+ expect(defer(true, 5_000)).toBe(true);
380
+ expect(defer(true, 10_000)).toBe(true);
381
+ expect(defer(true, 15_000)).toBe(true);
382
+ expect(defer(true, 20_000)).toBe(false); // bound reached — drains, not silenced forever
383
+ });
339
384
  });
340
385
 
341
386
  describe("obligationSweep — F2 decision half: a poke-cleared-but-busy session defers, then re-asks (bounded)", () => {
@@ -20,6 +20,7 @@ import {
20
20
  isSilentFlushMarker,
21
21
  isCompositeSilentNoise,
22
22
  endsWithSilentMarker,
23
+ isSilentSentinelCardOutcome,
23
24
  isTurnFlushSafetyEnabled,
24
25
  selectFlushDeliveryText,
25
26
  FLUSH_SUBSTANTIVE_MIN_CHARS,
@@ -893,3 +894,85 @@ describe('selectFlushDeliveryText — structural provenance (followedByToolUse)
893
894
  expect(out).not.toContain('Here are the figures')
894
895
  })
895
896
  })
897
+
898
+ // #4348 — the pure gate that suppresses the per-turn activity/telemetry card
899
+ // when the turn's whole user-facing outcome was an intentional silent sentinel.
900
+ describe('isSilentSentinelCardOutcome (#4348)', () => {
901
+ const base = { replyCalled: false, lastReplyText: '', capturedText: [] as string[], finalAnswerEverDelivered: false }
902
+
903
+ it('reply("NO_REPLY") — the blocked sentinel-only reply is a silent outcome', () => {
904
+ expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'NO_REPLY' })).toBe(true)
905
+ })
906
+
907
+ it('trailing punctuation and case variants still count as silent', () => {
908
+ for (const t of ['NO_REPLY.', 'no_reply', 'HEARTBEAT_OK', 'heartbeat_ok!', ' NO_REPLY ']) {
909
+ expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: t })).toBe(true)
910
+ }
911
+ })
912
+
913
+ it('composite silent noise ("Sent.\\nNO_REPLY\\nNO_REPLY") via the reply payload is silent', () => {
914
+ expect(
915
+ isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'Sent.\nNO_REPLY\nNO_REPLY' }),
916
+ ).toBe(true)
917
+ })
918
+
919
+ it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is silent', () => {
920
+ expect(
921
+ isSilentSentinelCardOutcome({
922
+ ...base,
923
+ replyCalled: false,
924
+ capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
925
+ }),
926
+ ).toBe(true)
927
+ })
928
+
929
+ it('a real reply is NOT silent — the card is a legitimate record', () => {
930
+ expect(
931
+ isSilentSentinelCardOutcome({
932
+ ...base,
933
+ replyCalled: true,
934
+ lastReplyText: 'The three services are all green.',
935
+ finalAnswerEverDelivered: true,
936
+ }),
937
+ ).toBe(false)
938
+ })
939
+
940
+ it('finalAnswerEverDelivered short-circuits even when a stray NO_REPLY is the last reply text', () => {
941
+ // An interim answer landed, then a trailing sentinel — the delivered answer wins.
942
+ expect(
943
+ isSilentSentinelCardOutcome({
944
+ ...base,
945
+ replyCalled: true,
946
+ lastReplyText: 'NO_REPLY',
947
+ finalAnswerEverDelivered: true,
948
+ }),
949
+ ).toBe(false)
950
+ })
951
+
952
+ it('a delivered reply that merely mentions the sentinel in prose is NOT silent', () => {
953
+ // Non-marker content ⇒ the sentinel-reply-guard would not drop it ⇒ delivered.
954
+ expect(
955
+ isSilentSentinelCardOutcome({
956
+ ...base,
957
+ replyCalled: true,
958
+ lastReplyText: 'Reply with exactly NO_REPLY if there is nothing to add.',
959
+ }),
960
+ ).toBe(false)
961
+ })
962
+
963
+ it('endsWithSilentMarker does NOT suppress a DELIVERED reply of "prose\\nNO_REPLY"', () => {
964
+ // The guard lets prose+trailing-NO_REPLY through the reply tool, so its
965
+ // prose reached chat; the card must stay even though it ends with a marker.
966
+ expect(
967
+ isSilentSentinelCardOutcome({
968
+ ...base,
969
+ replyCalled: true,
970
+ lastReplyText: 'Here is the full answer.\nNO_REPLY',
971
+ }),
972
+ ).toBe(false)
973
+ })
974
+
975
+ it('a genuinely empty dark turn (no reply, no text) is NOT a sentinel', () => {
976
+ expect(isSilentSentinelCardOutcome({ ...base })).toBe(false)
977
+ })
978
+ })
@@ -135,6 +135,85 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
135
135
  return isSilentFlushMarker(lines[lines.length - 1])
136
136
  }
137
137
 
138
+ /**
139
+ * Inputs for {@link isSilentSentinelCardOutcome} — the fields of the ending
140
+ * turn the card-suppression gate reads. All are already tracked on the
141
+ * gateway's `CurrentTurn`; passed as a plain struct so the decision is a pure,
142
+ * unit-testable core (`gateway.ts` / `narrative-lane.ts` are not importable in
143
+ * tests).
144
+ */
145
+ export interface SilentSentinelCardInput {
146
+ /** True when the model called `reply` / `stream_reply` at least once. */
147
+ replyCalled: boolean
148
+ /**
149
+ * The most-recent `reply` / `stream_reply` `input.text` this turn — empty
150
+ * string when the reply tool was never called (`CurrentTurn.lastReplyText`).
151
+ */
152
+ lastReplyText: string
153
+ /**
154
+ * Raw assistant text blocks accumulated across the turn — the same source
155
+ * `decideTurnFlush` classifies (`CurrentTurn.capturedText`). Only consulted
156
+ * on the reply-never-called (flush) path.
157
+ */
158
+ capturedText: string[]
159
+ /**
160
+ * A SUBSTANTIVE final answer reached the user at some point this turn
161
+ * (`CurrentTurn.finalAnswerEverDelivered`). When true the card is a
162
+ * legitimate record beside a delivered answer and is NEVER suppressed.
163
+ */
164
+ finalAnswerEverDelivered: boolean
165
+ }
166
+
167
+ /**
168
+ * Decide whether an ending turn's user-facing outcome was an INTENTIONAL silent
169
+ * sentinel (NO_REPLY / HEARTBEAT_OK) — the deterministic gate that suppresses
170
+ * the per-turn activity/telemetry card for a turn that said nothing to the user.
171
+ *
172
+ * The symptom (#4348): a background sub-agent handback injects a forced-synthesis
173
+ * turn, the parent legitimately answers `NO_REPLY`, and the gateway still
174
+ * finalizes a `🤖 Agent · done · 0 tools · … · ✓ NO_REPLY` card in the chat.
175
+ * `sentinel-reply-guard-pretool.mjs` already drops the sentinel-only reply so
176
+ * nothing reaches chat, but the blocked `tool_use` still stamps `lastReplyText`,
177
+ * and the activity card finalizes as pure noise.
178
+ *
179
+ * This reuses the SAME silent-turn predicates the flush safety net and the Stop
180
+ * hook use — it invents no second notion of "silent":
181
+ * - Reply path (`replyCalled`): the model called `reply` with a sentinel-ONLY
182
+ * payload, which `sentinel-reply-guard-pretool.mjs` drops before chat, so the
183
+ * card's whole outcome is a silence signal that never became a message. Only
184
+ * `isSilentFlushMarker` / `isCompositeSilentNoise` count here — a
185
+ * `prose\nNO_REPLY` reply is NOT dropped by that guard (it has non-marker
186
+ * content), so its prose WAS delivered and its card must stay. `endsWithSilentMarker`
187
+ * is deliberately NOT applied to a delivered reply.
188
+ * - Flush path (reply never called): the turn's captured terminal text is its
189
+ * outcome, so match every shape `decideTurnFlush` treats as `silent-marker` —
190
+ * bare marker, composite noise, and prose+trailing-`NO_REPLY` (#2053 / H6).
191
+ *
192
+ * The `finalAnswerEverDelivered` short-circuit is the normal-case guarantee: a
193
+ * turn that actually delivered a substantive answer keeps its card, so a real
194
+ * reply (even one a later stray `NO_REPLY` follows) is never suppressed.
195
+ */
196
+ export function isSilentSentinelCardOutcome(input: SilentSentinelCardInput): boolean {
197
+ // A substantive answer reached the user — the card is a legitimate record.
198
+ if (input.finalAnswerEverDelivered) return false
199
+ // Reply-tool outcome: a sentinel-ONLY payload the guard dropped before chat.
200
+ if (isSilentFlushMarker(input.lastReplyText) || isCompositeSilentNoise(input.lastReplyText)) {
201
+ return true
202
+ }
203
+ // Flush outcome: reply never called; classify the captured terminal text with
204
+ // the exact predicates decideTurnFlush's `silent-marker` skip uses.
205
+ if (!input.replyCalled) {
206
+ const joined = input.capturedText.join('\n\n').trim()
207
+ if (
208
+ joined.length > 0 &&
209
+ (isSilentFlushMarker(joined) || isCompositeSilentNoise(joined) || endsWithSilentMarker(joined))
210
+ ) {
211
+ return true
212
+ }
213
+ }
214
+ return false
215
+ }
216
+
138
217
  /**
139
218
  * Substantive-answer floor (chars, trimmed). Mirrors
140
219
  * `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and