switchroom 0.20.7 → 0.20.9

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +111 -14
  2. package/dist/auth-broker/index.js +113 -30
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +67 -6
  7. package/dist/cli/switchroom.js +389 -31
  8. package/dist/host-control/main.js +69 -8
  9. package/dist/vault/approvals/kernel-server.js +68 -7
  10. package/dist/vault/broker/server.js +68 -7
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  16. package/telegram-plugin/dist/gateway/gateway.js +410 -178
  17. package/telegram-plugin/dist/server.js +5 -2
  18. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  19. package/telegram-plugin/gateway/auth-command.ts +4 -2
  20. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  21. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  22. package/telegram-plugin/gateway/cron-session.ts +66 -0
  23. package/telegram-plugin/gateway/gateway.ts +36 -34
  24. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  25. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  26. package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
  27. package/telegram-plugin/gateway/stream-render.ts +11 -2
  28. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  29. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  31. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  32. package/telegram-plugin/render/line-start-guard.ts +27 -2
  33. package/telegram-plugin/sticker-aliases.ts +12 -14
  34. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  35. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  36. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  37. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  38. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  39. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  40. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  41. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  42. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  43. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  44. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  45. package/telegram-plugin/tests/represent-guard.test.ts +45 -0
  46. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  47. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  48. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  49. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  51. package/telegram-plugin/throttle-tier.ts +59 -0
  52. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -78,18 +78,117 @@ describe("guardAccidentalEmphasis (#3252) — intended emphasis is LEFT UNTOUCHE
78
78
  expect(guardAccidentalEmphasis(s)).toBe(s);
79
79
  });
80
80
 
81
- it("leaves space-flanked operators (`3 * 4`) verbatim — GFM cannot emphasise them", () => {
82
- const s = "the product 3 * 4 equals 12";
81
+ it("leaves a lone glob (`*.ts`) verbatim", () => {
82
+ const s = "match *.ts files only";
83
+ expect(guardAccidentalEmphasis(s)).toBe(s);
84
+ });
85
+ });
86
+
87
+ describe("guardAccidentalEmphasis (#3464) — boundary-flanked `*` (whitespace/boundary on BOTH sides) IS escaped", () => {
88
+ // A `*` with whitespace or a string boundary on both immediate sides is
89
+ // neither left- nor right-flanking under GFM — it can never open or close
90
+ // emphasis, so escaping it is always safe and never touches an intended span.
91
+ it("escapes a space-flanked operator `3 * 4` (flipped from the old leave-alone pin)", () => {
92
+ const out = guardAccidentalEmphasis("the product 3 * 4 equals 12");
93
+ expect(out).toBe("the product 3 \\* 4 equals 12");
94
+ expect(copyText(out)).toBe("the product 3 * 4 equals 12");
95
+ });
96
+
97
+ it("escapes a bare trailing glob `rm *` (space before, EOL after — boundary both sides)", () => {
98
+ // `run rm * to clear` — the `*` is whitespace-flanked on both sides.
99
+ const out = guardAccidentalEmphasis("run rm * to clear the dir");
100
+ expect(out).toBe("run rm \\* to clear the dir");
101
+ expect(copyText(out)).toBe("run rm * to clear the dir");
102
+ });
103
+
104
+ it("escapes a single space-flanked `*` in `a * b`", () => {
105
+ const out = guardAccidentalEmphasis("compute a * b now");
106
+ expect(out).toBe("compute a \\* b now");
107
+ expect(copyText(out)).toBe("compute a * b now");
108
+ });
109
+
110
+ it("escapes BOTH space-flanked `*` in `a * b * c`", () => {
111
+ const out = guardAccidentalEmphasis("compute a * b * c now");
112
+ expect(out).toBe("compute a \\* b \\* c now");
113
+ expect(copyText(out)).toBe("compute a * b * c now");
114
+ });
115
+
116
+ it("escapes a `*` at the string end (` *`) — boundary on the trailing side", () => {
117
+ const out = guardAccidentalEmphasis("clear with rm *");
118
+ expect(out).toBe("clear with rm \\*");
119
+ });
120
+
121
+ it("PRESERVES a line-leading `* ` — it is a list BULLET, not an operator (#3464 blocker)", () => {
122
+ // A line-leading `* ` cannot be disambiguated from a `*`-bullet, and Telegram
123
+ // renders it as a bullet regardless — matching origin/main. Escaping it would
124
+ // break `*`-bullets on every reply AND (since this arm runs before the
125
+ // heading guard) disarm the glued-`#` fix for `* #4382`. So it stays verbatim.
126
+ const s = "* is the multiply op";
83
127
  expect(guardAccidentalEmphasis(s)).toBe(s);
84
128
  });
85
129
 
86
- it("leaves a bare trailing glob (`rm *`) verbatim", () => {
87
- const s = "run rm * to clear the dir";
130
+ it("escapes a bare lone `*` (boundary on both sides, no trailing space → not a bullet)", () => {
131
+ expect(guardAccidentalEmphasis("*")).toBe("\\*");
132
+ });
133
+
134
+ it("leaves `*italic*` untouched (delimiters word-adjacent on the inner side)", () => {
135
+ const s = "this is *italic* text";
88
136
  expect(guardAccidentalEmphasis(s)).toBe(s);
89
137
  });
90
138
 
91
- it("leaves a lone glob (`*.ts`) verbatim", () => {
92
- const s = "match *.ts files only";
139
+ it("leaves `**bold**` untouched", () => {
140
+ const s = "this is **bold** text";
141
+ expect(guardAccidentalEmphasis(s)).toBe(s);
142
+ });
143
+
144
+ it("leaves intra-word `x*y` behaviour to the intra-word arm (single `*`, not boundary-flanked)", () => {
145
+ // A lone intra-word `*` (one asterisk total) stays byte-identical per the
146
+ // pair threshold; the boundary arm does not match it (alnum on both sides).
147
+ const s = "the value x*y here";
148
+ expect(guardAccidentalEmphasis(s)).toBe(s);
149
+ });
150
+
151
+ it("is idempotent on a boundary-flanked escape (double-apply is byte-identical)", () => {
152
+ const once = guardAccidentalEmphasis("a * b and c * d");
153
+ expect(guardAccidentalEmphasis(once)).toBe(once);
154
+ expect(once).not.toContain("\\\\*");
155
+ });
156
+
157
+ it("escapes a `*` alone on its own line (`\\n` counts as whitespace, no bullet)", () => {
158
+ const out = guardAccidentalEmphasis("line one\n*\nline two");
159
+ expect(out).toBe("line one\n\\*\nline two");
160
+ });
161
+ });
162
+
163
+ describe("guardAccidentalEmphasis (#3464) — line-leading `*` BULLETS are preserved (blocker 2)", () => {
164
+ it("leaves a multi-line `*`-bullet list unchanged", () => {
165
+ const s = "* bullet a\n* bullet b";
166
+ expect(guardAccidentalEmphasis(s)).toBe(s);
167
+ });
168
+
169
+ it("leaves a mixed `*`/`-` bullet list unchanged", () => {
170
+ const s = "* one\n- two\n* three";
171
+ expect(guardAccidentalEmphasis(s)).toBe(s);
172
+ });
173
+
174
+ it("leaves `+ item` and `- item` bullets unchanged (they carry no `*`)", () => {
175
+ expect(guardAccidentalEmphasis("+ item")).toBe("+ item");
176
+ expect(guardAccidentalEmphasis("- item")).toBe("- item");
177
+ });
178
+
179
+ it("leaves an indented (≤3 space) `*` bullet unchanged", () => {
180
+ const s = " * indented bullet";
181
+ expect(guardAccidentalEmphasis(s)).toBe(s);
182
+ });
183
+
184
+ it("STILL escapes a non-bullet `*` on a line that ALSO has a `*` bullet", () => {
185
+ // Line 1 is a bullet (preserved); line 2 has a space-flanked operator (escaped).
186
+ const out = guardAccidentalEmphasis("* bullet\ncompute a * b");
187
+ expect(out).toBe("* bullet\ncompute a \\* b");
188
+ });
189
+
190
+ it("is a strict no-op for a pure `*`-bullet list (arm stays disarmed)", () => {
191
+ const s = "* a\n* b\n* c";
93
192
  expect(guardAccidentalEmphasis(s)).toBe(s);
94
193
  });
95
194
  });
@@ -14,49 +14,43 @@ import { guardAccidentalFormatting } from "../../rich-send.js";
14
14
  // On the renderer-BYPASS seam (cards / banners / status / approval sends), no
15
15
  // such belt runs, so the glued `#` reaches Telegram unescaped.
16
16
  //
17
- // ── Why this test PINS the current behavior instead of changing it ───────────
18
- // Whether this is a bug depends on a fact we CANNOT determine from the byte
19
- // stream: does Telegram's non-spec Bot API rich parser actually promote a
20
- // space-less `#` to a heading when it sits AFTER a `>`/list marker, the way it
21
- // demonstrably does at a bare line start (`#3460` → giant heading, #3306/#3463)?
17
+ // ── Live UAT confirmed the promotion these pins are now FLIPPED (#3464) ─────
18
+ // The question was a fact we could NOT determine from the byte stream: does
19
+ // Telegram's non-spec Bot API rich parser actually promote a space-less `#` to a
20
+ // heading when it sits AFTER a `>`/list marker, the way it demonstrably does at
21
+ // a bare line start (`#3460` → giant heading, #3306/#3463)?
22
22
  // - CommonMark treats `> #3460` as a blockquote whose content is the paragraph
23
23
  // `#3460` (no ATX heading — no space after `#`); `> # Heading` (WITH space)
24
24
  // is a real nested heading. Telegram's promotion of the SPACE-LESS form is
25
- // the documented non-spec deviation — but only ever OBSERVED at a bare line
26
- // start, never confirmed inside a blockquote/list.
27
- // - There is no repo evidence (UAT fixture, doc note, or #3306/#3463 UAT
28
- // result) establishing that the promotion fires in this nested position.
29
- // The render.ts belt escaping it is a GENERIC side effect of a `^…#`
30
- // paragraph regex, not a confirmed-behavior signal.
31
- // Issue #3464 itself says: "Verify against Telegram live-UAT whether the
32
- // non-spec heading promotion actually fires inside blockquotes/lists before
33
- // adding escaping (avoid stray backslashes if it does not)." That live UAT
34
- // cannot run in vitest. Ken's hard constraint on this guard family is that a
35
- // wrong "fix" adding stray backslashes would itself corrupt legitimate
36
- // formatting — the exact thing to avoid. So this test DOCUMENTS the current,
37
- // deliberately-conservative behavior; if live UAT later confirms Telegram DOES
38
- // promote here, extend the guard and flip these expectations in the same PR.
25
+ // the documented non-spec deviation.
26
+ // Issue #3464 said: "Verify against Telegram live-UAT whether the non-spec
27
+ // heading promotion actually fires inside blockquotes/lists before adding
28
+ // escaping (avoid stray backslashes if it does not)." That live UAT has now run
29
+ // and CONFIRMED the promotion fires in the nested position a `#` glued after a
30
+ // `>`/list marker is promoted to a heading exactly as at a bare line start. So
31
+ // `guardAccidentalHeading` now escapes it (via `ACCIDENTAL_HEADING_AFTER_MARKER`
32
+ // in line-start-guard.ts), and the previously-pinned "left untouched"
33
+ // expectations are flipped to assert the escaped outcome. A real nested heading
34
+ // (`> # Heading`, space AFTER the `#`) and a bare `#` remain untouched (below).
39
35
 
40
- describe("guardAccidentalHeading — glued `#` after a blockquote/list marker is NOT escaped (#3464, awaits live-UAT)", () => {
41
- it("leaves `> #3460` untouched (glued hash after a blockquote marker)", () => {
42
- expect(guardAccidentalHeading("> #3460 done")).toBe("> #3460 done");
36
+ describe("guardAccidentalHeading — glued `#` after a blockquote/list marker IS escaped (#3464, live-UAT confirmed)", () => {
37
+ it("escapes `> #3460` (glued hash after a blockquote marker)", () => {
38
+ expect(guardAccidentalHeading("> #3460 done")).toBe("> \\#3460 done");
43
39
  });
44
40
 
45
- it("leaves `- #3460` untouched (glued hash after an unordered-list marker)", () => {
46
- expect(guardAccidentalHeading("- #3460 done")).toBe("- #3460 done");
41
+ it("escapes `- #3460` (glued hash after an unordered-list marker)", () => {
42
+ expect(guardAccidentalHeading("- #3460 done")).toBe("- \\#3460 done");
47
43
  });
48
44
 
49
- it("leaves `* #3460` untouched (glued hash after a `*` bullet)", () => {
50
- expect(guardAccidentalHeading("* #3460 x")).toBe("* #3460 x");
45
+ it("escapes `* #3460` (glued hash after a `*` bullet)", () => {
46
+ expect(guardAccidentalHeading("* #3460 x")).toBe("* \\#3460 x");
51
47
  });
52
48
 
53
- it("leaves `1. #3460` untouched (glued hash after an ordered-list marker)", () => {
54
- expect(guardAccidentalHeading("1. #3460 x")).toBe("1. #3460 x");
49
+ it("escapes `1. #3460` (glued hash after an ordered-list marker)", () => {
50
+ expect(guardAccidentalHeading("1. #3460 x")).toBe("1. \\#3460 x");
55
51
  });
56
52
 
57
- it("still escapes the SAME `#3460` at a bare line start (the confirmed case)", () => {
58
- // Proves the untouched results above are the `^`-anchor scope, not the guard
59
- // being disabled: at a real line start the accidental heading IS escaped.
53
+ it("still escapes the SAME `#3460` at a bare line start (the original confirmed case)", () => {
60
54
  expect(guardAccidentalHeading("#3460 done")).toBe("\\#3460 done");
61
55
  });
62
56
  });
@@ -71,16 +65,109 @@ describe("guardAccidentalHeading — a real nested heading (space form) must sta
71
65
  });
72
66
  });
73
67
 
74
- describe("guardAccidentalFormatting (universal seam) — same conservative behavior end-to-end (#3464)", () => {
75
- it("leaves `> #3460` untouched through the full composition", () => {
76
- expect(guardAccidentalFormatting("> #3460 done")).toBe("> #3460 done");
68
+ describe("guardAccidentalFormatting (universal seam) — glued `#` after a marker IS escaped end-to-end (#3464)", () => {
69
+ it("escapes `> #3460` through the full composition", () => {
70
+ expect(guardAccidentalFormatting("> #3460 done")).toBe("> \\#3460 done");
77
71
  });
78
72
 
79
- it("leaves `- #3460` untouched through the full composition", () => {
80
- expect(guardAccidentalFormatting("- #3460 done")).toBe("- #3460 done");
73
+ it("escapes `- #3460` through the full composition", () => {
74
+ expect(guardAccidentalFormatting("- #3460 done")).toBe("- \\#3460 done");
81
75
  });
82
76
 
83
77
  it("still escapes a bare line-leading `#3460` at the seam (control)", () => {
84
78
  expect(guardAccidentalFormatting("#3460 done")).toBe("\\#3460 done");
85
79
  });
86
80
  });
81
+
82
+ describe("guardAccidentalHeading (#3464) — every list/blockquote marker shape + nesting is escaped", () => {
83
+ it("escapes `- #4382's x` (apostrophe-suffixed hash after a `-` bullet)", () => {
84
+ expect(guardAccidentalHeading("- #4382's x")).toBe("- \\#4382's x");
85
+ });
86
+
87
+ it("escapes `* #x` (after a `*` bullet)", () => {
88
+ expect(guardAccidentalHeading("* #x")).toBe("* \\#x");
89
+ });
90
+
91
+ it("escapes `+ #x` (after a `+` bullet)", () => {
92
+ expect(guardAccidentalHeading("+ #x")).toBe("+ \\#x");
93
+ });
94
+
95
+ it("escapes `1. #x` (after a `.`-delimited ordered marker)", () => {
96
+ expect(guardAccidentalHeading("1. #x")).toBe("1. \\#x");
97
+ });
98
+
99
+ it("escapes `1) #x` (after a `)`-delimited ordered marker)", () => {
100
+ expect(guardAccidentalHeading("1) #x")).toBe("1) \\#x");
101
+ });
102
+
103
+ it("escapes `> #x` (after a blockquote marker)", () => {
104
+ expect(guardAccidentalHeading("> #x")).toBe("> \\#x");
105
+ });
106
+
107
+ it("escapes nested `- > #x` (a bullet then a blockquote marker)", () => {
108
+ expect(guardAccidentalHeading("- > #x")).toBe("- > \\#x");
109
+ });
110
+ });
111
+
112
+ describe("guardAccidentalHeading (#3464) — invariants that must stay untouched", () => {
113
+ it("leaves `- # Heading` (real nested heading, space after `#`)", () => {
114
+ expect(guardAccidentalHeading("- # Heading")).toBe("- # Heading");
115
+ });
116
+
117
+ it("leaves `# Heading` (real bare heading, space after `#`)", () => {
118
+ expect(guardAccidentalHeading("# Heading")).toBe("# Heading");
119
+ });
120
+
121
+ it("still escapes bare `#4382 done` (unchanged from before this PR)", () => {
122
+ expect(guardAccidentalHeading("#4382 done")).toBe("\\#4382 done");
123
+ });
124
+ });
125
+
126
+ describe("guardAccidentalFormatting (#3464) — `*`-bullet marker survives emphasis AND the glued `#` is escaped (blocker 1, end-to-end)", () => {
127
+ // The composed pipeline (rich-send.ts) runs guardAccidentalEmphasis BEFORE
128
+ // guardAccidentalHeading. If the emphasis arm escaped the leading `* ` bullet,
129
+ // the LITERAL `*` marker the heading guard needs would be gone and the glued
130
+ // `#` would NOT be escaped. These tests exercise the REAL ordering — the unit
131
+ // tests above call the heading guard in isolation and cannot catch that.
132
+ it("escapes `* #3460 x` end-to-end (marker preserved, `#` escaped)", () => {
133
+ expect(guardAccidentalFormatting("* #3460 x")).toBe("* \\#3460 x");
134
+ });
135
+
136
+ it("escapes `* #x` end-to-end", () => {
137
+ expect(guardAccidentalFormatting("* #x")).toBe("* \\#x");
138
+ });
139
+
140
+ it("escapes `- #3460 x` end-to-end (regression guard — `-` marker never touched)", () => {
141
+ expect(guardAccidentalFormatting("- #3460 x")).toBe("- \\#3460 x");
142
+ });
143
+
144
+ it("escapes `> #x` end-to-end", () => {
145
+ expect(guardAccidentalFormatting("> #x")).toBe("> \\#x");
146
+ });
147
+
148
+ it("leaves a plain `*`-bullet list unchanged end-to-end (no glued `#`)", () => {
149
+ const s = "* bullet a\n* bullet b";
150
+ expect(guardAccidentalFormatting(s)).toBe(s);
151
+ });
152
+
153
+ it("leaves a mixed `*`/`-` bullet list unchanged end-to-end", () => {
154
+ const s = "* one\n- two\n* three";
155
+ expect(guardAccidentalFormatting(s)).toBe(s);
156
+ });
157
+ });
158
+
159
+ describe("guardAccidentalHeading (#3464) — idempotence (double-apply is byte-identical)", () => {
160
+ for (const input of [
161
+ "- #4382 done",
162
+ "> #x",
163
+ "- > #x",
164
+ "1. #x",
165
+ "#4382 done",
166
+ ]) {
167
+ it(`is idempotent on ${JSON.stringify(input)}`, () => {
168
+ const once = guardAccidentalHeading(input);
169
+ expect(guardAccidentalHeading(once)).toBe(once);
170
+ expect(once).not.toContain("\\\\#");
171
+ });
172
+ }
173
+ });
@@ -433,3 +433,50 @@ describe('quote_text lands on the wire as ReplyParameters.quote (a String)', ()
433
433
  expect(long.startsWith(quote)).toBe(true)
434
434
  })
435
435
  })
436
+
437
+ /**
438
+ * #4368 — a fabricated reply anchor on the wire.
439
+ *
440
+ * The model's `reply` tool can quote a SYNTHETIC inbound: a boot-resume /
441
+ * subagent-handback / cron turn fabricates a `message_id` from `Date.now()`
442
+ * (~1.78e13). Telegram's `reply_parameters.message_id` HARD-rejects anything
443
+ * out of the signed-int32 range (400 `field "message_id" must be a valid
444
+ * Number`), and `allow_sending_without_reply` does NOT bypass that — so quoting
445
+ * a synthetic id used to 400 EVERY chunk of the reply, losing the answer.
446
+ *
447
+ * `executeReply` (whose body is `sendReply`) must route `args.reply_to` through
448
+ * `parseSourceMessageId` so an out-of-range anchor is DROPPED and the reply
449
+ * lands UNANCHORED. This reads what actually goes over the wire — a builder-
450
+ * level assertion would not catch a future opts transform re-introducing it.
451
+ */
452
+ describe('synthetic reply anchor is dropped on the wire (#4368)', () => {
453
+ it('quoting an out-of-int32 reply_to sends UNANCHORED and still delivers', async () => {
454
+ const h = makeHarness()
455
+ const res = await sendReply(h.deps, {
456
+ args: {
457
+ chat_id: CHAT,
458
+ text: 'The answer must land even when the quoted inbound was synthetic.',
459
+ reply_to: 1_785_000_000_000,
460
+ },
461
+ turn: null,
462
+ })
463
+
464
+ const sends = h.sends()
465
+ expect(sends.length).toBeGreaterThan(0)
466
+ // The fabricated id never reaches the wire: no reply_parameters at all …
467
+ expect(h.replyParams(sends[0])).toBeUndefined()
468
+ // … and specifically no out-of-range message_id anywhere in the payload.
469
+ expect(JSON.stringify(sends[0].payload)).not.toContain('1785000000000')
470
+ // … and the answer actually delivered (not a failure notice).
471
+ expect(res.content[0]?.text ?? '').toMatch(/sent/i)
472
+ })
473
+
474
+ it('a real (in-range) reply_to is still honored as a quote anchor', async () => {
475
+ const h = makeHarness()
476
+ await sendReply(h.deps, {
477
+ args: { chat_id: CHAT, text: 'ok', reply_to: 4242 },
478
+ turn: null,
479
+ })
480
+ expect(h.replyParams(h.sends()[0])!).toEqual({ message_id: 4242 })
481
+ })
482
+ })
@@ -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)", () => {
@@ -230,3 +230,46 @@ describe('resolveGifSendArgs', () => {
230
230
  expect(r.replyTo).toBe(88)
231
231
  })
232
232
  })
233
+
234
+ // #4368 — a fabricated reply anchor (a synthetic boot-resume/handback/cron
235
+ // inbound id at `Date.now()` scale, ~1.78e13) is out of the signed-int32 range
236
+ // Telegram accepts for `reply_parameters.message_id`. It must be DROPPED at the
237
+ // resolver boundary so the sticker/GIF still sends (unanchored) rather than the
238
+ // agent's echoed synthetic id 400ing the whole send. Before the fix these
239
+ // resolvers ran `Number(raw.reply_to)`, which is finite and > 0 for a 13-digit
240
+ // timestamp, so the fabricated id passed straight through onto the wire.
241
+ const SYNTHETIC_MESSAGE_ID = String(1_785_000_000_000)
242
+
243
+ describe('resolveStickerSendArgs — synthetic reply anchor (#4368)', () => {
244
+ it('drops an out-of-int32 reply_to so the sticker sends unanchored', () => {
245
+ const r = resolveStickerSendArgs(
246
+ { chat_id: '1', sticker: SAMPLE_FILE_ID, reply_to: SYNTHETIC_MESSAGE_ID },
247
+ {},
248
+ )
249
+ expect(r.replyTo).toBeUndefined()
250
+ })
251
+
252
+ it('still preserves a real (in-range) reply_to', () => {
253
+ const r = resolveStickerSendArgs(
254
+ { chat_id: '1', sticker: SAMPLE_FILE_ID, reply_to: '4242' },
255
+ {},
256
+ )
257
+ expect(r.replyTo).toBe(4242)
258
+ })
259
+ })
260
+
261
+ describe('resolveGifSendArgs — synthetic reply anchor (#4368)', () => {
262
+ it('drops an out-of-int32 reply_to so the GIF sends unanchored', () => {
263
+ const r = resolveGifSendArgs({
264
+ chat_id: '1',
265
+ gif: SAMPLE_FILE_ID,
266
+ reply_to: SYNTHETIC_MESSAGE_ID,
267
+ })
268
+ expect(r.replyTo).toBeUndefined()
269
+ })
270
+
271
+ it('still preserves a real (in-range) reply_to', () => {
272
+ const r = resolveGifSendArgs({ chat_id: '1', gif: SAMPLE_FILE_ID, reply_to: '4242' })
273
+ expect(r.replyTo).toBe(4242)
274
+ })
275
+ })