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.
- package/dist/agent-scheduler/index.js +16 -13
- package/dist/auth-broker/index.js +51 -29
- package/dist/cli/autoaccept-poll.js +5 -3
- package/dist/cli/drive-write-pretool.mjs +5 -3
- package/dist/cli/ms-365-write-pretool.mjs +5 -3
- package/dist/cli/notion-write-pretool.mjs +6 -6
- package/dist/cli/switchroom.js +12 -10
- package/dist/host-control/main.js +7 -7
- package/dist/vault/approvals/kernel-server.js +6 -6
- package/dist/vault/broker/server.js +6 -6
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +12 -13
- package/telegram-plugin/ask-user.ts +6 -7
- package/telegram-plugin/dist/gateway/gateway.js +183 -66
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +4 -2
- package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
- package/telegram-plugin/gateway/gateway.ts +8 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
- package/telegram-plugin/render/emphasis-guard.ts +92 -12
- package/telegram-plugin/render/line-start-guard.ts +27 -2
- package/telegram-plugin/sticker-aliases.ts +12 -14
- package/telegram-plugin/tests/ask-user.test.ts +15 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
- package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
- package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
- package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
- package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
- package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
- package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
- 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
|
|
82
|
-
const s = "
|
|
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("
|
|
87
|
-
|
|
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
|
|
92
|
-
const s = "
|
|
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
|
-
// ──
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
|
41
|
-
it("
|
|
42
|
-
expect(guardAccidentalHeading("> #3460 done")).toBe(">
|
|
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("
|
|
46
|
-
expect(guardAccidentalHeading("- #3460 done")).toBe("-
|
|
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("
|
|
50
|
-
expect(guardAccidentalHeading("* #3460 x")).toBe("*
|
|
45
|
+
it("escapes `* #3460` (glued hash after a `*` bullet)", () => {
|
|
46
|
+
expect(guardAccidentalHeading("* #3460 x")).toBe("* \\#3460 x");
|
|
51
47
|
});
|
|
52
48
|
|
|
53
|
-
it("
|
|
54
|
-
expect(guardAccidentalHeading("1. #3460 x")).toBe("1.
|
|
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) —
|
|
75
|
-
it("
|
|
76
|
-
expect(guardAccidentalFormatting("> #3460 done")).toBe(">
|
|
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("
|
|
80
|
-
expect(guardAccidentalFormatting("- #3460 done")).toBe("-
|
|
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
|
+
})
|