switchroom 0.20.8 → 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 (36) hide show
  1. package/dist/agent-scheduler/index.js +16 -13
  2. package/dist/auth-broker/index.js +51 -29
  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 +6 -6
  7. package/dist/cli/switchroom.js +12 -10
  8. package/dist/host-control/main.js +7 -7
  9. package/dist/vault/approvals/kernel-server.js +6 -6
  10. package/dist/vault/broker/server.js +6 -6
  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/dist/gateway/gateway.js +183 -66
  15. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  16. package/telegram-plugin/gateway/auth-command.ts +4 -2
  17. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  18. package/telegram-plugin/gateway/gateway.ts +8 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  20. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  21. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  22. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  23. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  24. package/telegram-plugin/render/line-start-guard.ts +27 -2
  25. package/telegram-plugin/sticker-aliases.ts +12 -14
  26. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  27. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  28. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  29. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  30. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  31. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  32. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  33. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  34. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  35. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  36. package/telegram-plugin/throttle-tier.ts +59 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Double-wake dedup (v0.20.8 candidate) — one background sub-agent completion
3
+ * must produce ONE parent wake, not two.
4
+ *
5
+ * The bug: a single background sub-agent completion fanned out to TWO
6
+ * independent wakes of the parent session —
7
+ * 1. the claude CLI's own `<task-notification>` (enqueued by the CLI itself;
8
+ * switchroom read it only as a background-shell liveness signal), and
9
+ * 2. the gateway-synthesized `subagent_handback` inbound (subagent-watcher
10
+ * `onFinish` → `pendingInboundBuffer.push`).
11
+ * Nothing linked them. The CLI-native wake cannot be suppressed (it is the
12
+ * CLI's internal queue), so the fix gates the ONE lever switchroom holds — the
13
+ * handback enqueue — on a recently-seen terminal `<task-notification>` for
14
+ * EXACTLY the same task/agent id (`CliTaskNotificationLedger`,
15
+ * subagent-handback-marker.ts; consumed by `decideSubagentHandback`).
16
+ *
17
+ * FAIL-OPEN contract pinned here: every uncertain path DELIVERS the handback.
18
+ * A dropped real wake (silent worker, user waits forever) is strictly worse
19
+ * than an occasional double.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ CliTaskNotificationLedger,
25
+ TASK_NOTIFICATION_DEDUP_TTL_MS,
26
+ } from '../gateway/subagent-handback-marker.js'
27
+ import { decideSubagentHandback } from '../gateway/subagent-handback-inbound-builder.js'
28
+ import { projectTranscriptLine } from '../session-tail.js'
29
+ import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
30
+ import { readFileSync } from 'node:fs'
31
+ import { join, dirname } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ const T0 = 1_700_000_000_000
35
+
36
+ const base = {
37
+ handbackEnvValue: undefined as string | undefined,
38
+ outcome: 'completed' as 'completed' | 'failed' | 'orphan',
39
+ isBackground: true,
40
+ fleetChatId: '777',
41
+ ownerChatId: '999',
42
+ taskDescription: 'Do the thing',
43
+ resultText: 'Done.',
44
+ jsonlAgentId: 'a204deeaedb27b580',
45
+ nowMs: T0,
46
+ }
47
+
48
+ // A real captured queue-operation enqueue line shape (fixture
49
+ // bg-shell-liveness-3519.jsonl), retargeted at an AGENT task id — the live
50
+ // transcripts prove `<task-id>` for a backgrounded agent equals the
51
+ // `agent-<id>.jsonl` stem the subagent-watcher uses as `agentId`
52
+ // (verified: <task-id>a204deeaedb27b580</task-id> ↔
53
+ // subagents/agent-a204deeaedb27b580.jsonl).
54
+ function notifLine(taskId: string, status: string): string {
55
+ return JSON.stringify({
56
+ type: 'queue-operation',
57
+ operation: 'enqueue',
58
+ timestamp: '2026-08-04T00:00:00.000Z',
59
+ sessionId: 'deadbeef-0000-4000-8000-000000000000',
60
+ content:
61
+ `<task-notification>\n<task-id>${taskId}</task-id>\n` +
62
+ `<tool-use-id>toolu_01TESTTESTTESTTEST</tool-use-id>\n` +
63
+ `<output-file>/tmp/tasks/${taskId}.output</output-file>\n` +
64
+ `<status>${status}</status>\n<summary>Agent done</summary>\n</task-notification>`,
65
+ })
66
+ }
67
+
68
+ describe('CliTaskNotificationLedger', () => {
69
+ it('records a terminal notification and reports it within the TTL', () => {
70
+ const l = new CliTaskNotificationLedger()
71
+ l.record('a204deeaedb27b580', 'completed', T0)
72
+ expect(l.seenRecently('a204deeaedb27b580', T0 + 5_000)).toBe(true)
73
+ expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS)).toBe(true)
74
+ })
75
+
76
+ it('fail-open: expires after the TTL (a resumed worker second completion delivers)', () => {
77
+ const l = new CliTaskNotificationLedger()
78
+ l.record('a204deeaedb27b580', 'completed', T0)
79
+ expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
80
+ })
81
+
82
+ it('fail-open: a DIFFERENT task id never matches', () => {
83
+ const l = new CliTaskNotificationLedger()
84
+ l.record('a204deeaedb27b580', 'completed', T0)
85
+ expect(l.seenRecently('a999999999999999', T0 + 1)).toBe(false)
86
+ })
87
+
88
+ it('fail-open: non-terminal statuses and empty ids are never recorded', () => {
89
+ const l = new CliTaskNotificationLedger()
90
+ l.record('a204deeaedb27b580', 'running', T0)
91
+ l.record('', 'completed', T0)
92
+ expect(l.seenRecently('a204deeaedb27b580', T0 + 1)).toBe(false)
93
+ expect(l.seenRecently('', T0 + 1)).toBe(false)
94
+ })
95
+
96
+ it('records failed and killed as terminal (the CLI wakes the parent for those too)', () => {
97
+ const l = new CliTaskNotificationLedger()
98
+ l.record('aaa', 'failed', T0)
99
+ l.record('bbb', 'killed', T0)
100
+ expect(l.seenRecently('aaa', T0 + 1)).toBe(true)
101
+ expect(l.seenRecently('bbb', T0 + 1)).toBe(true)
102
+ })
103
+
104
+ it('prunes expired entries on write (bounded memory)', () => {
105
+ const l = new CliTaskNotificationLedger()
106
+ l.record('old', 'completed', T0)
107
+ l.record('new', 'completed', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_000)
108
+ // The old entry is both expired AND physically pruned; either way the
109
+ // observable contract is: it no longer suppresses.
110
+ expect(l.seenRecently('old', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(false)
111
+ expect(l.seenRecently('new', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(true)
112
+ })
113
+ })
114
+
115
+ describe('decideSubagentHandback × CLI task-notification dedup', () => {
116
+ // ── Outcome (a): one background completion → exactly ONE parent wake ──────
117
+ // RED-before/GREEN-after core: on pre-fix code this decision delivered a
118
+ // second wake; post-fix it is suppressed with the dedicated reason.
119
+ it('suppresses the handback when the CLI already woke the parent for this exact completion', () => {
120
+ const ledger = new CliTaskNotificationLedger()
121
+ // The CLI's wake for this completion (wake #1) is observed by the tail…
122
+ for (const ev of projectTranscriptLine(notifLine('a204deeaedb27b580', 'completed'))) {
123
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
124
+ }
125
+ // …then the watcher's onFinish runs the decide gate (would-be wake #2).
126
+ const wakes: string[] = ['cli-task-notification'] // wake #1 already happened
127
+ const d = decideSubagentHandback({
128
+ ...base,
129
+ cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
130
+ })
131
+ if (d.deliver) wakes.push('subagent_handback')
132
+ expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
133
+ expect(wakes).toHaveLength(1) // exactly one parent wake, not two
134
+ })
135
+
136
+ it('suppresses a FAILED completion the CLI already reported, too', () => {
137
+ const d = decideSubagentHandback({
138
+ ...base,
139
+ outcome: 'failed',
140
+ cliTaskNotificationSeen: true,
141
+ })
142
+ expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
143
+ })
144
+
145
+ // ── Outcome (b): fail-open — a handback with NO matching notification fires ─
146
+ it('fail-open: delivers when no notification was seen (flag false)', () => {
147
+ const d = decideSubagentHandback({ ...base, cliTaskNotificationSeen: false })
148
+ expect(d.deliver).toBe(true)
149
+ if (d.deliver) {
150
+ expect(d.chatId).toBe('777')
151
+ expect(d.inbound.meta?.source).toBe('subagent_handback')
152
+ }
153
+ })
154
+
155
+ it('fail-open: delivers when the flag is omitted entirely (older callers)', () => {
156
+ const d = decideSubagentHandback({ ...base })
157
+ expect(d.deliver).toBe(true)
158
+ })
159
+
160
+ it('fail-open end-to-end: a notification for a DIFFERENT agent id does not suppress', () => {
161
+ const ledger = new CliTaskNotificationLedger()
162
+ for (const ev of projectTranscriptLine(notifLine('a999999999999999', 'completed'))) {
163
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
164
+ }
165
+ const d = decideSubagentHandback({
166
+ ...base,
167
+ cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
168
+ })
169
+ expect(d.deliver).toBe(true)
170
+ })
171
+
172
+ it('fail-open end-to-end: an EXPIRED notification does not suppress', () => {
173
+ const ledger = new CliTaskNotificationLedger()
174
+ ledger.record(base.jsonlAgentId, 'completed', T0)
175
+ const d = decideSubagentHandback({
176
+ ...base,
177
+ cliTaskNotificationSeen: ledger.seenRecently(
178
+ base.jsonlAgentId,
179
+ T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1,
180
+ ),
181
+ })
182
+ expect(d.deliver).toBe(true)
183
+ })
184
+
185
+ it('gate ordering: foreground/outcome/env gates still win over the dedup reason', () => {
186
+ expect(
187
+ decideSubagentHandback({ ...base, isBackground: false, cliTaskNotificationSeen: true }),
188
+ ).toEqual({ deliver: false, reason: 'foreground' })
189
+ expect(
190
+ decideSubagentHandback({ ...base, outcome: 'orphan', cliTaskNotificationSeen: true }),
191
+ ).toEqual({ deliver: false, reason: 'outcome-not-terminal' })
192
+ expect(
193
+ decideSubagentHandback({ ...base, handbackEnvValue: '0', cliTaskNotificationSeen: true }),
194
+ ).toEqual({ deliver: false, reason: 'env-disabled' })
195
+ })
196
+ })
197
+
198
+ // ── Outcome (c): the task-notification keeps working as a liveness signal ────
199
+ // Recording into the dedup ledger is an ADDITIVE read of the same event; the
200
+ // background-shell-liveness consumer must still see the shell as DEAD.
201
+ describe('task_notification still drives background-shell liveness (not swallowed)', () => {
202
+ it('one projected event feeds BOTH the liveness registry and the dedup ledger', () => {
203
+ const dead: string[] = []
204
+ const alive: string[] = []
205
+ const registry = {
206
+ noteBackgroundShellAlive: (_k: string, id: string) => void alive.push(id),
207
+ noteBackgroundShellDead: (_k: string, id: string) => void dead.push(id),
208
+ }
209
+ const ledger = new CliTaskNotificationLedger()
210
+ const events = projectTranscriptLine(notifLine('bxa4sv3dq', 'completed'))
211
+ expect(events).toHaveLength(1)
212
+ for (const ev of events) {
213
+ applyBackgroundShellLiveness(registry, 'chat:-', ev)
214
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
215
+ }
216
+ expect(dead).toEqual(['bxa4sv3dq']) // liveness signal intact
217
+ expect(ledger.seenRecently('bxa4sv3dq', T0 + 1)).toBe(true) // dedup recorded
218
+ expect(alive).toEqual([])
219
+ })
220
+
221
+ it('a foreground-shell task-notification with NO handback in flight changes nothing else', () => {
222
+ // A background Bash shell death has no subagent-watcher onFinish — the
223
+ // ledger entry simply ages out; nothing consults it for shells.
224
+ const ledger = new CliTaskNotificationLedger()
225
+ ledger.record('bxa4sv3dq', 'completed', T0)
226
+ expect(ledger.seenRecently('bxa4sv3dq', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
227
+ })
228
+ })
229
+
230
+ // ── Wiring pins — the gateway must actually consult the ledger ───────────────
231
+ // gateway.ts is not unit-instantiable; pin the two wiring sites statically
232
+ // (repo precedent: subagent-handback-marker.test.ts scans gateway source).
233
+ describe('gateway wiring pins', () => {
234
+ const here = dirname(fileURLToPath(import.meta.url))
235
+ const gatewaySrc = readFileSync(join(here, '..', 'gateway', 'gateway.ts'), 'utf8')
236
+
237
+ it('onSessionEvent records terminal task-notifications into the ledger', () => {
238
+ expect(gatewaySrc).toMatch(
239
+ /if \(ev\.kind === 'task_notification'\) cliTaskNotifLedger\.record\(ev\.taskId, ev\.status, Date\.now\(\)\)/,
240
+ )
241
+ })
242
+
243
+ it('the onFinish decide callsite passes the fail-open seenRecently read', () => {
244
+ expect(gatewaySrc).toMatch(
245
+ /cliTaskNotificationSeen: cliTaskNotifLedger\.seenRecently\(agentId, Date\.now\(\)\)/,
246
+ )
247
+ })
248
+ })
@@ -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
+ })
@@ -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
+ })