switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queued-card-store.test.ts — outcome-based tests for the queued-status /
|
|
3
|
+
* busy-ack card restart-survivability fix (#3002).
|
|
4
|
+
*
|
|
5
|
+
* PRE-FIX PROOF: before this change, no persistence existed for the
|
|
6
|
+
* `queuedStatusMsgIds` Map at all — `../gateway/queued-card-store.js` did not
|
|
7
|
+
* exist, so `import` itself failed at collection time and every test below
|
|
8
|
+
* failed on today's (pre-fix) base — there was no seam to test.
|
|
9
|
+
*
|
|
10
|
+
* This file exercises the REAL exported store + boot-reaper functions (not a
|
|
11
|
+
* structural grep), mirroring `activity-card-store.test.ts`'s in-memory fs seam
|
|
12
|
+
* convention, proving the exact outcome contract:
|
|
13
|
+
* (a) card post persists a record;
|
|
14
|
+
* (b) in-process reap clears it;
|
|
15
|
+
* (c) a boot reaper against a leftover record performs EXACTLY ONE Telegram
|
|
16
|
+
* delete and empties the store;
|
|
17
|
+
* (d) a second boot performs ZERO deletes;
|
|
18
|
+
* (e) a corrupt state file never crashes boot and is dropped without a delete;
|
|
19
|
+
* (f) a delete failure never crashes the reaper and still clears the row.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import {
|
|
23
|
+
loadQueuedCards,
|
|
24
|
+
persistQueuedCards,
|
|
25
|
+
writeQueuedCardRecord,
|
|
26
|
+
clearQueuedCardRecord,
|
|
27
|
+
runQueuedCardBootReaper,
|
|
28
|
+
type QueuedCardRecord,
|
|
29
|
+
type QueuedCardStoreFsSeam,
|
|
30
|
+
} from '../gateway/queued-card-store.js'
|
|
31
|
+
|
|
32
|
+
/** In-memory fs seam with an atomic rename — same convention as
|
|
33
|
+
* activity-card-store.test.ts's memFs, so the tmp→rename crash-safety
|
|
34
|
+
* contract is exercised without touching real disk. */
|
|
35
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
36
|
+
const files = new Map<string, string>(Object.entries(seed))
|
|
37
|
+
const calls: string[] = []
|
|
38
|
+
const fs: QueuedCardStoreFsSeam = {
|
|
39
|
+
readFileSync: (p) => {
|
|
40
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`)
|
|
41
|
+
return files.get(p)!
|
|
42
|
+
},
|
|
43
|
+
writeFileSync: (p, d) => {
|
|
44
|
+
calls.push(`write:${p}`)
|
|
45
|
+
files.set(p, d)
|
|
46
|
+
},
|
|
47
|
+
renameSync: (a, b) => {
|
|
48
|
+
calls.push(`rename:${a}->${b}`)
|
|
49
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`)
|
|
50
|
+
files.set(b, files.get(a)!)
|
|
51
|
+
files.delete(a)
|
|
52
|
+
},
|
|
53
|
+
existsSync: (p) => files.has(p),
|
|
54
|
+
}
|
|
55
|
+
return { fs, files, calls }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PATH = '/state/agent/telegram/queued-cards-pending.json'
|
|
59
|
+
|
|
60
|
+
function card(over: Partial<QueuedCardRecord> = {}): QueuedCardRecord {
|
|
61
|
+
return {
|
|
62
|
+
key: '-100123:42',
|
|
63
|
+
chatId: '-100123',
|
|
64
|
+
threadId: 42,
|
|
65
|
+
messageId: 715,
|
|
66
|
+
...over,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('queued-card-store — persistence primitives', () => {
|
|
71
|
+
it('loads [] when no file exists (fresh boot)', () => {
|
|
72
|
+
const { fs } = memFs()
|
|
73
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('(a) card post → state persisted (round-trips a written record)', () => {
|
|
77
|
+
const { fs } = memFs()
|
|
78
|
+
const record = card()
|
|
79
|
+
writeQueuedCardRecord(PATH, fs, record)
|
|
80
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
81
|
+
expect(loaded).toHaveLength(1)
|
|
82
|
+
expect(loaded[0]).toEqual(record)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('round-trips a DM card (threadId null)', () => {
|
|
86
|
+
const { fs } = memFs()
|
|
87
|
+
const record = card({ key: '-100123:', threadId: null })
|
|
88
|
+
writeQueuedCardRecord(PATH, fs, record)
|
|
89
|
+
expect(loadQueuedCards(PATH, fs)[0]).toEqual(record)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('a fresh post for the same topic replaces (upserts) the stale row, not appends', () => {
|
|
93
|
+
const { fs } = memFs()
|
|
94
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
95
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
|
|
96
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
97
|
+
expect(loaded).toHaveLength(1)
|
|
98
|
+
expect(loaded[0]!.messageId).toBe(900)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps rows for distinct keys (different topics) side by side', () => {
|
|
102
|
+
const { fs } = memFs()
|
|
103
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
|
|
104
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
|
|
105
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('(b) in-process reap → state cleared', () => {
|
|
109
|
+
const { fs } = memFs()
|
|
110
|
+
writeQueuedCardRecord(PATH, fs, card())
|
|
111
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
|
|
112
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42')
|
|
113
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('clearing an absent/mismatched key is a no-op (never throws)', () => {
|
|
117
|
+
const { fs } = memFs()
|
|
118
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:99' }))
|
|
119
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42')
|
|
120
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reap-race guard: clear scoped to messageId leaves a fresh live card intact', () => {
|
|
124
|
+
const { fs } = memFs()
|
|
125
|
+
// A live turn posted a NEW card (msg 900) under the same key while a stale
|
|
126
|
+
// reap tries to clear the OLD one (msg 715) — the new row must survive.
|
|
127
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
|
|
128
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42', 715)
|
|
129
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
130
|
+
expect(loaded).toHaveLength(1)
|
|
131
|
+
expect(loaded[0]!.messageId).toBe(900)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('writes via tmp + atomic rename (crash-safe)', () => {
|
|
135
|
+
const { fs, calls } = memFs()
|
|
136
|
+
persistQueuedCards(PATH, fs, [card()])
|
|
137
|
+
expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
|
|
141
|
+
const { fs } = memFs({ [PATH]: '{not json' })
|
|
142
|
+
expect(() => loadQueuedCards(PATH, fs)).not.toThrow()
|
|
143
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('fail-open: wrong envelope version / malformed shape loads as []', () => {
|
|
147
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
|
|
148
|
+
expect(loadQueuedCards(PATH, f1)).toEqual([])
|
|
149
|
+
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
|
|
150
|
+
expect(loadQueuedCards(PATH, f2)).toEqual([])
|
|
151
|
+
const { fs: f3 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: [{ key: '' }] }) })
|
|
152
|
+
expect(loadQueuedCards(PATH, f3)).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('queued-card-store — boot reaper (reap-on-boot, delete is the terminal)', () => {
|
|
157
|
+
it('(c) deletes each persisted card EXACTLY ONCE and empties the store', async () => {
|
|
158
|
+
const { fs } = memFs()
|
|
159
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
|
|
160
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
|
|
161
|
+
const deletes: number[] = []
|
|
162
|
+
const res = await runQueuedCardBootReaper({
|
|
163
|
+
path: PATH,
|
|
164
|
+
fs,
|
|
165
|
+
deleteCard: async (r) => {
|
|
166
|
+
deletes.push(r.messageId)
|
|
167
|
+
return { message_id: r.messageId }
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
expect(res).toEqual({ deleted: 2, total: 2 })
|
|
171
|
+
expect(deletes.sort()).toEqual([715, 716])
|
|
172
|
+
// Store is emptied — the stale cards can never be re-reaped.
|
|
173
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('(d) a SECOND boot performs ZERO deletes (store already drained)', async () => {
|
|
177
|
+
const { fs } = memFs()
|
|
178
|
+
writeQueuedCardRecord(PATH, fs, card())
|
|
179
|
+
await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
|
|
180
|
+
const deletes: number[] = []
|
|
181
|
+
const res = await runQueuedCardBootReaper({
|
|
182
|
+
path: PATH,
|
|
183
|
+
fs,
|
|
184
|
+
deleteCard: async (r) => {
|
|
185
|
+
deletes.push(r.messageId)
|
|
186
|
+
return {}
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
expect(res).toEqual({ deleted: 0, total: 0 })
|
|
190
|
+
expect(deletes).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('deletes the record BEFORE the delete call (idempotency guard — no double-delete on crash)', async () => {
|
|
194
|
+
const { fs } = memFs()
|
|
195
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
196
|
+
let storeAtDeleteTime: QueuedCardRecord[] | null = null
|
|
197
|
+
await runQueuedCardBootReaper({
|
|
198
|
+
path: PATH,
|
|
199
|
+
fs,
|
|
200
|
+
deleteCard: async () => {
|
|
201
|
+
// At the moment the Telegram delete runs, the row is already gone from
|
|
202
|
+
// disk — a crash here or a concurrent second boot cannot re-issue it.
|
|
203
|
+
storeAtDeleteTime = loadQueuedCards(PATH, fs)
|
|
204
|
+
return {}
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
expect(storeAtDeleteTime).toEqual([])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('(f) a delete FAILURE never crashes the reaper and still clears the row (best-effort, tolerate "not found")', async () => {
|
|
211
|
+
const { fs } = memFs()
|
|
212
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
213
|
+
const res = await runQueuedCardBootReaper({
|
|
214
|
+
path: PATH,
|
|
215
|
+
fs,
|
|
216
|
+
deleteCard: async () => {
|
|
217
|
+
throw new Error('Bad Request: message to delete not found')
|
|
218
|
+
},
|
|
219
|
+
log: () => {},
|
|
220
|
+
})
|
|
221
|
+
// Not counted as deleted, but the row is dropped (delete-first) so it can't
|
|
222
|
+
// wedge every future boot.
|
|
223
|
+
expect(res).toEqual({ deleted: 0, total: 1 })
|
|
224
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('empty store: reaper is a no-op', async () => {
|
|
228
|
+
const { fs } = memFs()
|
|
229
|
+
const res = await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
|
|
230
|
+
expect(res).toEqual({ deleted: 0, total: 0 })
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2094 finding 3 — reaction-flush uses the #1556 turn gate.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG: flushReactionBatch delivered its synthetic reaction inbound with a
|
|
5
|
+
* bare `ipcServer.sendToAgent` + busy-mark, buffering ONLY on a bridge-offline
|
|
6
|
+
* miss. It never ran the #1556 decideInboundDelivery gate. So a reaction that
|
|
7
|
+
* landed WHILE a turn was in flight fired the MCP channel notification mid-turn
|
|
8
|
+
* — the unmodified CLI typed it into its TUI composer and the auto-submit raced
|
|
9
|
+
* turn-completion, re-creating the composer wedge.
|
|
10
|
+
*
|
|
11
|
+
* THE FIX: route the flush through deliverResumeSyntheticOrBuffer, the shared
|
|
12
|
+
* helper that consults the gate (mid-turn → buffer-until-idle, flushed by the
|
|
13
|
+
* turn-complete hook + idle-drain timer; idle → deliver now; buffer only on a
|
|
14
|
+
* genuine miss).
|
|
15
|
+
*
|
|
16
|
+
* gateway.ts is a large side-effecting module that can't be imported into a
|
|
17
|
+
* unit test, so this file pins (a) the gate decision for a reaction inbound's
|
|
18
|
+
* shape via the pure gate + a modeled buffer/drain, and (b) that
|
|
19
|
+
* flushReactionBatch wires the turn-safe helper structurally — the same
|
|
20
|
+
* strategy button-tap-turn-gated.test.ts uses.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { resolve } from 'node:path'
|
|
25
|
+
import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
|
|
26
|
+
import { planBufferedRedelivery } from '../gateway/pending-inbound-buffer.js'
|
|
27
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
28
|
+
|
|
29
|
+
const gatewaySrc = readFileSync(
|
|
30
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
31
|
+
'utf-8',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// A reaction inbound as flushReactionBatch builds it: synthetic messageId (ts),
|
|
35
|
+
// never steering, never an interrupt — the exact inputs the shared helper
|
|
36
|
+
// (deliverResumeSyntheticOrBuffer) passes to the gate.
|
|
37
|
+
function reactionInbound(ts: number): InboundMessage {
|
|
38
|
+
return {
|
|
39
|
+
type: 'inbound',
|
|
40
|
+
chatId: 'c1',
|
|
41
|
+
messageId: ts,
|
|
42
|
+
user: 'u',
|
|
43
|
+
userId: 42,
|
|
44
|
+
ts,
|
|
45
|
+
text: `[user reacted 👍 to your message]`,
|
|
46
|
+
meta: { chat_id: 'c1', count: '1' },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('reaction flush uses the turn-gate (mid-turn → buffer)', () => {
|
|
51
|
+
it('buffers a reaction that lands mid-turn, delivers when idle, never mid-turn', () => {
|
|
52
|
+
const shape = { isSteering: false as const, isInterrupt: false as const }
|
|
53
|
+
// Reaction lands WHILE a turn is in flight → HELD, not sent (the wedge the
|
|
54
|
+
// raw sendToAgent used to cause).
|
|
55
|
+
expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
|
|
56
|
+
'buffer-until-idle',
|
|
57
|
+
)
|
|
58
|
+
// Reaction lands at an idle prompt → delivered immediately.
|
|
59
|
+
expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
|
|
60
|
+
'deliver',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a mid-turn-buffered reaction drains as a fresh turn after turn end', () => {
|
|
65
|
+
// Model the buffer→drain: the reaction was held mid-turn; on turn-complete
|
|
66
|
+
// the buffer is planned for redelivery and the reaction lands cleanly as
|
|
67
|
+
// its own turn (never merged away / dropped).
|
|
68
|
+
const plan = planBufferedRedelivery([reactionInbound(1000)])
|
|
69
|
+
expect(plan).toHaveLength(1)
|
|
70
|
+
expect(plan[0]!.merged.text).toBe('[user reacted 👍 to your message]')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('flushReactionBatch wires the turn-safe helper', () => {
|
|
75
|
+
const start = gatewaySrc.indexOf('function flushReactionBatch(batch: ReactionBatch)')
|
|
76
|
+
// Bound the body by the next top-level declaration (the message_reaction
|
|
77
|
+
// handler block).
|
|
78
|
+
const body = gatewaySrc.slice(
|
|
79
|
+
start,
|
|
80
|
+
gatewaySrc.indexOf("bot.on('message_reaction'", start),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it('exists', () => {
|
|
84
|
+
expect(start, 'flushReactionBatch missing').toBeGreaterThan(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('delivers via deliverResumeSyntheticOrBuffer, not a raw ungated sendToAgent', () => {
|
|
88
|
+
expect(body).toMatch(/deliverResumeSyntheticOrBuffer\(agentName, inbound\)/)
|
|
89
|
+
// The regressed path — a raw ungated send of the reaction inbound + an
|
|
90
|
+
// out-of-helper busy-mark — is gone.
|
|
91
|
+
expect(body).not.toMatch(/ipcServer\.sendToAgent\(agentName, inbound\)/)
|
|
92
|
+
expect(body).not.toMatch(/markClaudeBusyForInbound\(inbound\)/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('no longer open-codes the buffer-on-failure push (the helper owns it)', () => {
|
|
96
|
+
// The #1150 buffer-on-failure guarantee now lives inside
|
|
97
|
+
// deliverResumeSyntheticOrBuffer, so the flush body must not push directly.
|
|
98
|
+
expect(body).not.toMatch(/pendingInboundBuffer\.push\(/)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3155 — `setMessageReaction` must route through the send gate + flood breaker.
|
|
3
|
+
*
|
|
4
|
+
* Before this change ~13 raw `bot.api.setMessageReaction(...)` /
|
|
5
|
+
* `lockedBot.api.setMessageReaction(...)` call sites (early-ack 👀, status
|
|
6
|
+
* reactions, stop/interrupt ⚡, permission verdict ✅/❌, refusal 🚫, ack-only,
|
|
7
|
+
* the MCP `react` tool, the crash-recovery sweeps, the auth-code 🔑 redact)
|
|
8
|
+
* fired reactions OUTSIDE both:
|
|
9
|
+
*
|
|
10
|
+
* (a) the send gate — so reaction churn was never paced/shed under flood
|
|
11
|
+
* pressure even with the gate ON; and
|
|
12
|
+
* (b) the flood circuit breaker — a 429 from a reaction never reached the
|
|
13
|
+
* retry module's `onFloodWait` hook, so the ban went unrecorded.
|
|
14
|
+
*
|
|
15
|
+
* Reactions were therefore a residual flood vector even when the gate is on.
|
|
16
|
+
* The gateway now funnels every reaction through ONE seam,
|
|
17
|
+
* `robustApiCall(() => lockedBot.api.setMessageReaction(...), { priorityClass:
|
|
18
|
+
* 'cosmetic', verb: 'set-message-reaction' })`.
|
|
19
|
+
*
|
|
20
|
+
* Two levels of coverage:
|
|
21
|
+
* 1. BEHAVIOURAL — compose the real `send-gate` + `retry-api-call` modules the
|
|
22
|
+
* same way `gateway.ts` wires `robustApiCall`, and prove a cosmetic
|
|
23
|
+
* reaction is (a) shed when a flood window is open and (b) visible to
|
|
24
|
+
* `onFloodWait` on a 429.
|
|
25
|
+
* 2. LOAD-BEARING SOURCE SCAN — assert the gateway has exactly ONE runtime
|
|
26
|
+
* `setMessageReaction` call and that it is the cosmetic seam. This FAILS if
|
|
27
|
+
* any future change re-introduces a raw (ungated, breaker-blind) reaction.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
30
|
+
import { readFileSync } from 'node:fs'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { createSendGate, type Clock } from '../send-gate.js'
|
|
33
|
+
import { createRetryApiCall } from '../retry-api-call.js'
|
|
34
|
+
import { errors } from './fake-bot-api.js'
|
|
35
|
+
|
|
36
|
+
const GATEWAY_SRC = fileURLToPath(new URL('../gateway/gateway.ts', import.meta.url))
|
|
37
|
+
|
|
38
|
+
/** Fixed-time clock; `sleep` is a no-op so nothing actually waits under test. */
|
|
39
|
+
function fixedClock(now: number): Clock {
|
|
40
|
+
return { now: () => now, sleep: () => Promise.resolve() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reconstruct the gateway's outbound wiring for reactions: `robustApiCall =
|
|
45
|
+
* gate(rawRetry)`, and a `sendReaction` closure that tags the call `cosmetic`
|
|
46
|
+
* exactly like `gatedSetMessageReaction` in gateway.ts.
|
|
47
|
+
*/
|
|
48
|
+
function wireReactionPath(opts: {
|
|
49
|
+
clock: Clock
|
|
50
|
+
enabled: boolean
|
|
51
|
+
windowUntilTs?: number
|
|
52
|
+
onFloodWait?: (retryAfterSec: number) => void
|
|
53
|
+
}) {
|
|
54
|
+
const setMessageReaction = vi.fn(async () => true as const)
|
|
55
|
+
const sendGate = createSendGate({
|
|
56
|
+
enabled: opts.enabled,
|
|
57
|
+
clock: opts.clock,
|
|
58
|
+
...(opts.windowUntilTs != null
|
|
59
|
+
? { initialWindows: [{ scopeKey: 'global', untilTs: opts.windowUntilTs }] }
|
|
60
|
+
: {}),
|
|
61
|
+
})
|
|
62
|
+
const rawRetry = createRetryApiCall({
|
|
63
|
+
maxRetries: 2,
|
|
64
|
+
sleep: async () => {},
|
|
65
|
+
...(opts.onFloodWait ? { onFloodWait: opts.onFloodWait } : {}),
|
|
66
|
+
})
|
|
67
|
+
const robustApiCall = <T>(
|
|
68
|
+
fn: () => Promise<T>,
|
|
69
|
+
o?: Parameters<typeof rawRetry<T>>[1],
|
|
70
|
+
): Promise<T> => sendGate.gate(() => rawRetry(fn, o), o)
|
|
71
|
+
|
|
72
|
+
// Mirrors gateway.ts `gatedSetMessageReaction` / `sendReaction`.
|
|
73
|
+
const sendReaction = (chatId: string, messageId: number, emoji: string): Promise<unknown> =>
|
|
74
|
+
robustApiCall(() => setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]), {
|
|
75
|
+
chat_id: chatId,
|
|
76
|
+
verb: 'set-message-reaction',
|
|
77
|
+
priorityClass: 'cosmetic',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return { sendReaction, setMessageReaction, sendGate }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('#3155 reactions route through the send gate (cosmetic)', () => {
|
|
84
|
+
it('SHEDS a reaction while a flood window is open (paced/shed by the gate)', async () => {
|
|
85
|
+
const now = 1_000_000
|
|
86
|
+
const { sendReaction, setMessageReaction, sendGate } = wireReactionPath({
|
|
87
|
+
clock: fixedClock(now),
|
|
88
|
+
enabled: true,
|
|
89
|
+
windowUntilTs: now + 60_000, // an open global ban window
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const result = await sendReaction('chatA', 42, '👀')
|
|
93
|
+
|
|
94
|
+
// Cosmetic + a covering window ⇒ shed: the API is NEVER hit, the promise
|
|
95
|
+
// resolves undefined (fire-and-forget callers .catch nothing), and the gate
|
|
96
|
+
// counts the shed.
|
|
97
|
+
expect(setMessageReaction).not.toHaveBeenCalled()
|
|
98
|
+
expect(result).toBeUndefined()
|
|
99
|
+
expect(sendGate.stats().global.shed).toBe(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does NOT shed when the gate is OFF (proves the gate is what sheds)', async () => {
|
|
103
|
+
const now = 1_000_000
|
|
104
|
+
const { sendReaction, setMessageReaction } = wireReactionPath({
|
|
105
|
+
clock: fixedClock(now),
|
|
106
|
+
enabled: false, // gate disabled ⇒ pure passthrough
|
|
107
|
+
windowUntilTs: now + 60_000,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await sendReaction('chatA', 42, '👀')
|
|
111
|
+
|
|
112
|
+
// Flag OFF ⇒ the reaction still fires (the window only bites when enabled).
|
|
113
|
+
expect(setMessageReaction).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('records a 429 on a reaction to the flood breaker via onFloodWait', async () => {
|
|
117
|
+
const now = 1_000_000
|
|
118
|
+
const onFloodWait = vi.fn()
|
|
119
|
+
const { sendReaction, setMessageReaction } = wireReactionPath({
|
|
120
|
+
clock: fixedClock(now),
|
|
121
|
+
enabled: true, // gate on, no open window ⇒ the cosmetic call is admitted
|
|
122
|
+
onFloodWait,
|
|
123
|
+
})
|
|
124
|
+
// The reaction send hits a Telegram 429 flood-wait.
|
|
125
|
+
setMessageReaction.mockRejectedValue(errors.floodWait(7, 'setMessageReaction'))
|
|
126
|
+
|
|
127
|
+
// Fire-and-forget semantics: callers swallow. The point is the SIDE EFFECT.
|
|
128
|
+
await sendReaction('chatA', 42, '🤝').catch(() => {})
|
|
129
|
+
|
|
130
|
+
// The reaction actually reached the API (was admitted, not shed)...
|
|
131
|
+
expect(setMessageReaction).toHaveBeenCalled()
|
|
132
|
+
// ...and its 429 was recorded by the breaker — the whole gap this closes.
|
|
133
|
+
expect(onFloodWait).toHaveBeenCalledWith(7)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('#3155 load-bearing: gateway.ts sends no reaction raw', () => {
|
|
138
|
+
const src = readFileSync(GATEWAY_SRC, 'utf-8')
|
|
139
|
+
const lines = src.split('\n')
|
|
140
|
+
|
|
141
|
+
/** Non-comment source lines that call `(bot|lockedBot).api.setMessageReaction`. */
|
|
142
|
+
const rawReactionLines = lines
|
|
143
|
+
.map((text, i) => ({ text, n: i + 1 }))
|
|
144
|
+
.filter(({ text }) => {
|
|
145
|
+
const t = text.trim()
|
|
146
|
+
if (t.startsWith('*') || t.startsWith('//') || t.startsWith('/*')) return false
|
|
147
|
+
return /\b(bot|lockedBot)\.api\.setMessageReaction\b/.test(text)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('has EXACTLY ONE runtime setMessageReaction callsite (the shared seam)', () => {
|
|
151
|
+
// If this fails with >1, a raw reaction was re-introduced somewhere —
|
|
152
|
+
// ungated and invisible to the flood breaker. Route it through
|
|
153
|
+
// `sendReaction` / `gatedSetMessageReaction` instead.
|
|
154
|
+
expect(rawReactionLines).toHaveLength(1)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('the sole seam is tagged cosmetic and routed through robustApiCall', () => {
|
|
158
|
+
const seamLine = rawReactionLines[0]!.n
|
|
159
|
+
// Look at the seam line + a small window for its opts object.
|
|
160
|
+
const window = lines.slice(seamLine - 2, seamLine + 6).join('\n')
|
|
161
|
+
expect(window).toContain('robustApiCall(')
|
|
162
|
+
expect(window).toContain("priorityClass: 'cosmetic'")
|
|
163
|
+
expect(window).toContain("verb: 'set-message-reaction'")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('no reaction is fired as a raw fire-and-forget bot.api call', () => {
|
|
167
|
+
// Belt-and-braces: the old `bot.api.setMessageReaction(...).catch(() => {})`
|
|
168
|
+
// shape must be gone entirely.
|
|
169
|
+
expect(src).not.toMatch(/bot\.api\.setMessageReaction\([^)]*\)\s*\.catch/)
|
|
170
|
+
// And the fire-and-forget callers now go through the helper.
|
|
171
|
+
expect(src).toContain('void sendReaction(')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -48,6 +48,65 @@ describe("render: inline palette", () => {
|
|
|
48
48
|
"[label](https://example.com)",
|
|
49
49
|
);
|
|
50
50
|
});
|
|
51
|
+
it("escapes a `)` in a link href so the URL is not truncated (F3)", () => {
|
|
52
|
+
// An angle-bracket destination is the only way to smuggle an UNBALANCED
|
|
53
|
+
// `)` into an href via the parser (a bare `)` would otherwise close the
|
|
54
|
+
// destination). CommonMark strips the angle brackets → href holds the `)`.
|
|
55
|
+
const doc = parse("[wiki](<https://example.com/a)b>)");
|
|
56
|
+
const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
57
|
+
expect(link.href).toBe("https://example.com/a)b"); // parser captured the full URL
|
|
58
|
+
|
|
59
|
+
const out = render(doc);
|
|
60
|
+
// The `)` is backslash-escaped so it can no longer terminate the `(...)`
|
|
61
|
+
// destination early — a valid Bot API 10.1 link, not a broken one.
|
|
62
|
+
expect(out).toContain("a\\)b");
|
|
63
|
+
// And it round-trips: re-parsing recovers the SAME href, not a truncated
|
|
64
|
+
// `https://example.com/a` with stray `b)` prose spilled after it.
|
|
65
|
+
const reparsed = parse(out);
|
|
66
|
+
const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
67
|
+
expect(reLink.href).toBe("https://example.com/a)b");
|
|
68
|
+
});
|
|
69
|
+
it("round-trips a balanced-paren href (Wikipedia disambiguation) without leaking a backslash", () => {
|
|
70
|
+
// A bare destination with BALANCED parens is legal CommonMark; the parser
|
|
71
|
+
// captures the whole URL including the inner `(...)`. Escaping only `)`
|
|
72
|
+
// (the old fix) would unbalance the parens and leak a literal backslash
|
|
73
|
+
// into the decoded href. Escaping both parens keeps it balanced.
|
|
74
|
+
const src = "[w](https://en.wikipedia.org/wiki/Foo_(disambiguation))";
|
|
75
|
+
const doc = parse(src);
|
|
76
|
+
const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
77
|
+
expect(link.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
|
|
78
|
+
|
|
79
|
+
const out = render(doc);
|
|
80
|
+
const reparsed = parse(out);
|
|
81
|
+
const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
82
|
+
// Re-parsed href is byte-for-byte the original — no truncation, no stray `\`.
|
|
83
|
+
expect(reLink.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
|
|
84
|
+
expect(reLink.href).not.toContain("\\");
|
|
85
|
+
});
|
|
86
|
+
it("round-trips an href with multiple balanced paren groups", () => {
|
|
87
|
+
const src = "[m](https://example.com/a(b)c(d))";
|
|
88
|
+
const doc = parse(src);
|
|
89
|
+
const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
90
|
+
expect(link.href).toBe("https://example.com/a(b)c(d)");
|
|
91
|
+
|
|
92
|
+
const out = render(doc);
|
|
93
|
+
const reparsed = parse(out);
|
|
94
|
+
const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
95
|
+
expect(reLink.href).toBe("https://example.com/a(b)c(d)");
|
|
96
|
+
expect(reLink.href).not.toContain("\\");
|
|
97
|
+
});
|
|
98
|
+
it("round-trips an href with a lone unbalanced `)` without leaking a backslash", () => {
|
|
99
|
+
// Angle-bracket destination smuggles a lone `)` past the parser.
|
|
100
|
+
const doc = parse("[x](<https://example.com/a)b>)");
|
|
101
|
+
const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
102
|
+
expect(link.href).toBe("https://example.com/a)b");
|
|
103
|
+
|
|
104
|
+
const out = render(doc);
|
|
105
|
+
const reparsed = parse(out);
|
|
106
|
+
const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
|
|
107
|
+
expect(reLink.href).toBe("https://example.com/a)b");
|
|
108
|
+
expect(reLink.href).not.toContain("\\");
|
|
109
|
+
});
|
|
51
110
|
it("underline", () => {
|
|
52
111
|
expect(render(parse("__hi__"))).toBe("__hi__");
|
|
53
112
|
});
|
|
@@ -226,6 +285,35 @@ describe("render: block palette", () => {
|
|
|
226
285
|
const out = render(parse(md));
|
|
227
286
|
expect(out).toContain("**bold** cell");
|
|
228
287
|
});
|
|
288
|
+
it("neutralizes a `|` inside a code span in a table cell so the row survives (F4)", () => {
|
|
289
|
+
// Source pipe inside the code span is `\|`-escaped so the INPUT is a valid
|
|
290
|
+
// GFM table; the parser folds it to a code node whose text is `a|b`.
|
|
291
|
+
const md = ["| Col |", "| --- |", "| `a\\|b` |"].join("\n");
|
|
292
|
+
const doc = parse(md);
|
|
293
|
+
const srcCell = (doc.blocks[0] as TableNode).rows[0].cells[0].children.find(
|
|
294
|
+
(c: any) => c.type === "code",
|
|
295
|
+
) as any;
|
|
296
|
+
expect(srcCell.text).toBe("a|b"); // parser captured the literal pipe
|
|
297
|
+
|
|
298
|
+
const out = render(doc);
|
|
299
|
+
// The `|` is re-escaped inside the code span so it can't be read as a
|
|
300
|
+
// column separator and tear the row — valid Bot API 10.1 GFM output.
|
|
301
|
+
expect(out).toContain("`a\\|b`");
|
|
302
|
+
// Every rendered table line stays a structurally-valid row (`|`-delimited,
|
|
303
|
+
// balanced) — a torn row would start with `|` but not end with one.
|
|
304
|
+
for (const line of out.split("\n")) {
|
|
305
|
+
if (line.trimStart().startsWith("|")) {
|
|
306
|
+
expect(line.trimEnd().endsWith("|")).toBe(true);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// And it round-trips: the re-parsed table still has ONE body cell whose
|
|
310
|
+
// code text is `a|b`, not a torn row with an unterminated code span.
|
|
311
|
+
const reparsed = parse(out);
|
|
312
|
+
const reRow = (reparsed.blocks[0] as TableNode).rows[0];
|
|
313
|
+
expect(reRow.cells).toHaveLength(1);
|
|
314
|
+
const reCell = reRow.cells[0].children.find((c: any) => c.type === "code") as any;
|
|
315
|
+
expect(reCell.text).toBe("a|b");
|
|
316
|
+
});
|
|
229
317
|
});
|
|
230
318
|
|
|
231
319
|
describe("render: full document", () => {
|