switchroom 0.19.19 โ 0.19.22
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/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned-card COLLAPSED readability (#3666).
|
|
3
|
+
*
|
|
4
|
+
* The defect these tests exist to catch is invisible in the expanded render:
|
|
5
|
+
* the `๐ Workers` card is well-formed in the chat feed, but Telegram's
|
|
6
|
+
* pinned-message bar shows it collapsed onto ONE line โ the newlines are
|
|
7
|
+
* dropped and NOTHING is substituted โ so the last glyph of each line mashed
|
|
8
|
+
* into the first glyph of the next:
|
|
9
|
+
*
|
|
10
|
+
* ๐ Workers ยท 3 running1. Fix issue 3627 โฆ ยท opus 5โ Reading gateway.tsโ Runโฆ
|
|
11
|
+
*
|
|
12
|
+
* Every existing golden asserts the EXPANDED body and passed throughout. So
|
|
13
|
+
* these assertions all run against the COLLAPSED string, modelled by
|
|
14
|
+
* `previewLines` below, and the discriminating control ("the same lines
|
|
15
|
+
* joined WITHOUT collapseSafe still mash") is asserted explicitly so the suite
|
|
16
|
+
* would fail on the pre-fix render.
|
|
17
|
+
*
|
|
18
|
+
* The collapse itself is client-side: there is no Bot API for a separate
|
|
19
|
+
* pin-bar preview, so the message text is the only lever and a test can only
|
|
20
|
+
* model the client. The model is deliberately conservative โ it drops strictly
|
|
21
|
+
* more than Telegram does (all inline markers, all newlines).
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from 'vitest'
|
|
24
|
+
import {
|
|
25
|
+
renderActivityFeed,
|
|
26
|
+
renderActivityFeedWithNested,
|
|
27
|
+
renderCombinedWorkerFeed,
|
|
28
|
+
renderStatusCard,
|
|
29
|
+
} from '../tool-activity-summary.js'
|
|
30
|
+
import { renderWorkerActivity } from '../worker-activity-feed.js'
|
|
31
|
+
import { stackCardLines, COLLAPSE_SAFE_SEPARATOR as NB } from '../card-format.js'
|
|
32
|
+
import {
|
|
33
|
+
STATUS_CARD_CHAR_BUDGET,
|
|
34
|
+
STATUS_LINE_MAX,
|
|
35
|
+
WORKER_STEP_INDENT,
|
|
36
|
+
} from '../status-no-truncate.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Model Telegram's pinned-bar render of a card body, per line. ONE function
|
|
40
|
+
* backs both the per-line and the whole-string view so the two can never drift:
|
|
41
|
+
*
|
|
42
|
+
* - inline entity markup (`**`, `~~`, `_`) is formatting, not text -> dropped
|
|
43
|
+
* - the two spaces before each `\n` are GFM hard-break SYNTAX, consumed by
|
|
44
|
+
* the parser -> dropped
|
|
45
|
+
* - a LEADING whitespace run on a line is dropped by Telegram's server-side
|
|
46
|
+
* CommonMark parser (reference/telegram-formatting-guide.md:118, "Telegram
|
|
47
|
+
* drops leading whitespace"). Modelled as ASCII **and** Unicode-whitespace
|
|
48
|
+
* (`\p{White_Space}`, which covers the Zs category): #3662 shipped a U+00A0
|
|
49
|
+
* indent on the assumption that only ASCII was stripped, and a live phone
|
|
50
|
+
* check on 2026-07-26 proved otherwise โ the card rendered flat. Modelling
|
|
51
|
+
* this is what makes the nested-card assertion below DISCRIMINATING instead
|
|
52
|
+
* of vacuous: `NESTED_PREFIX` is three ASCII spaces (#3668, still open), so
|
|
53
|
+
* it cannot hold a collapsed seam apart โ only the separator can.
|
|
54
|
+
* `WORKER_STEP_INDENT` is now U+2800 (category So, not whitespace at all),
|
|
55
|
+
* which survives this strip.
|
|
56
|
+
* - the newline itself is dropped with no substitute (this is the defect)
|
|
57
|
+
*/
|
|
58
|
+
function previewLines(body: string): string[] {
|
|
59
|
+
return body
|
|
60
|
+
.split(/[ \t]*\n/)
|
|
61
|
+
.map((l) => l.replace(/^[\s\p{White_Space}]+/u, '').replace(/\*\*|~~|_/g, ''))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The whole collapsed one-line preview. */
|
|
65
|
+
function collapsePreview(body: string): string {
|
|
66
|
+
return previewLines(body).join('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Split a rendered card body back into its pre-join lines, markup intact. */
|
|
70
|
+
function rawCardLines(body: string): string[] {
|
|
71
|
+
return body.split(/[ \t]*\n/)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The outcome under test: at EVERY line seam, the two lines' text must not run
|
|
76
|
+
* together in the collapsed string. Asserted as a property over the whole card
|
|
77
|
+
* rather than on one hand-picked boundary, because the reported preview spanned
|
|
78
|
+
* three different seams and a line-1-only separator would have fixed one.
|
|
79
|
+
*
|
|
80
|
+
* Blank entries (a genuine `\n\n` paragraph gap) are dropped first rather than
|
|
81
|
+
* skipped: the empty string contributes NO character to a collapsed render, so
|
|
82
|
+
* the seam that actually reaches the user is the one between the content lines
|
|
83
|
+
* either side of the gap. Skipping instead of dropping would silently stop
|
|
84
|
+
* checking that seam.
|
|
85
|
+
*/
|
|
86
|
+
function expectNoMashedSeams(body: string): void {
|
|
87
|
+
const lines = previewLines(body).filter((l) => l !== '')
|
|
88
|
+
const collapsed = collapsePreview(body)
|
|
89
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
90
|
+
const prev = lines[i]
|
|
91
|
+
const next = lines[i + 1]
|
|
92
|
+
const seam = prev.slice(-1) + next.slice(0, 1)
|
|
93
|
+
expect(/\s/.test(seam), `seam ${i} mashes: "${seam}"`).toBe(true)
|
|
94
|
+
// โฆand the mashed spelling must not appear anywhere in what the user reads.
|
|
95
|
+
const mashed = prev.replace(/\s+$/, '').slice(-4) + next.slice(0, 4)
|
|
96
|
+
expect(collapsed.includes(mashed), `collapsed contains "${mashed}"`).toBe(false)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The three workers from the issue report, verbatim. */
|
|
101
|
+
const REPORTED_ROWS = [
|
|
102
|
+
{
|
|
103
|
+
description: 'Fix issue 3627 vault approval UX',
|
|
104
|
+
elapsedMs: 2_296_000,
|
|
105
|
+
toolCount: 164,
|
|
106
|
+
currentStep: 'Running search',
|
|
107
|
+
historyLines: ['Reading gateway.ts', 'Running search'],
|
|
108
|
+
totalTokens: 512_300,
|
|
109
|
+
model: 'claude-opus-5',
|
|
110
|
+
ordinal: 1,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
description: 'Adopt release workflow',
|
|
114
|
+
elapsedMs: 660_000,
|
|
115
|
+
toolCount: 20,
|
|
116
|
+
currentStep: 'Editing release.yml',
|
|
117
|
+
ordinal: 2,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
description: 'Fix setup verification false green',
|
|
121
|
+
elapsedMs: 640_000,
|
|
122
|
+
toolCount: 12,
|
|
123
|
+
currentStep: 'Running tests',
|
|
124
|
+
ordinal: 3,
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
describe('combined worker card survives the pinned-bar collapse (#3666)', () => {
|
|
129
|
+
const body = renderCombinedWorkerFeed(REPORTED_ROWS, { maxRows: 8 })!
|
|
130
|
+
|
|
131
|
+
it('has no mashed seam anywhere in the collapsed preview', () => {
|
|
132
|
+
expectNoMashedSeams(body)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('kills the exact artifacts from the report', () => {
|
|
136
|
+
const collapsed = collapsePreview(body)
|
|
137
|
+
// 1. the count/ordinal collision โ glance line into row 1's ordinal.
|
|
138
|
+
// This seam is owned by the collapse separator (a worker HEADER carries
|
|
139
|
+
// no leading indent, so nothing else separates it).
|
|
140
|
+
// (The reported spelling was `3 running1.`; with the packed glance line
|
|
141
|
+
// the same seam now reads `โฆ 512.3k tok` -> `1. Fix issue`, so assert on
|
|
142
|
+
// the CURRENT last token of line 1 โ an assertion on the old spelling
|
|
143
|
+
// alone would be vacuously green.)
|
|
144
|
+
expect(collapsed).not.toContain('running1.')
|
|
145
|
+
expect(collapsed).not.toContain('tok1.')
|
|
146
|
+
expect(collapsed).toContain(`tok${NB}1. Fix issue`)
|
|
147
|
+
// 2. the mid-word โ (model tag running into the step trail). Here the
|
|
148
|
+
// separator and WORKER_STEP_INDENT (three U+2800 leading a step line)
|
|
149
|
+
// stack, so the seam is separator + indent, asserted against both
|
|
150
|
+
// constants rather than a hardcoded run of spaces.
|
|
151
|
+
expect(collapsed).not.toContain('opus 5โ')
|
|
152
|
+
expect(collapsed).toContain(`opus 5${NB}${WORKER_STEP_INDENT}โ`)
|
|
153
|
+
// 3. the step trail running into the next step, and into the next row's
|
|
154
|
+
// header (that last seam is separator-only: headers are unindented).
|
|
155
|
+
expect(collapsed).not.toContain('gateway.tsโ')
|
|
156
|
+
expect(collapsed).toContain(`gateway.ts${NB}${WORKER_STEP_INDENT}โ`)
|
|
157
|
+
expect(collapsed).not.toContain('search2.')
|
|
158
|
+
expect(collapsed).toContain(`search${NB}2.`)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('leads with a self-contained glance that ends in a unit word, not a bare number', () => {
|
|
162
|
+
const first = previewLines(body)[0].replace(new RegExp(NB, 'g'), '')
|
|
163
|
+
expect(first).toContain('3 running')
|
|
164
|
+
expect(first).toContain('oldest 38m16s') // max elapsed across all rows
|
|
165
|
+
expect(first).toContain('196 tools') // 164 + 20 + 12
|
|
166
|
+
expect(first).toContain('512.3k tok')
|
|
167
|
+
// A bare trailing number would collide with row 1's `1.` even WITH a
|
|
168
|
+
// separator ("โฆ 196 1. Fix issue"); the glance always ends in a unit.
|
|
169
|
+
expect(first).toMatch(/(running|tools?|tok|\d+s)$/)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('CONTROL: the same lines joined without collapseSafe still mash (pre-fix shape)', () => {
|
|
173
|
+
// Discriminator: re-join the SAME rendered lines with the separator removed
|
|
174
|
+
// and show the collapsed preview mashes again. Without this, the assertions
|
|
175
|
+
// above could all be passing for reasons unrelated to the fix.
|
|
176
|
+
//
|
|
177
|
+
// The seams asserted here are the ones the separator alone owns: a worker
|
|
178
|
+
// HEADER line carries no leading WORKER_STEP_INDENT, so the
|
|
179
|
+
// glance->row-1 and step->next-row seams have nothing else holding them
|
|
180
|
+
// apart. (The header->step and step->step seams are separated by the indent
|
|
181
|
+
// even pre-fix, which is why they are not the control.)
|
|
182
|
+
const lines = rawCardLines(body).map((l) => l.replace(new RegExp(NB + '$'), ''))
|
|
183
|
+
const preFix = stackCardLines(lines)
|
|
184
|
+
const collapsed = collapsePreview(preFix)
|
|
185
|
+
expect(collapsed).toContain('tok1.')
|
|
186
|
+
expect(collapsed).toContain('search2.')
|
|
187
|
+
// โฆand the property assertion itself would have failed on it.
|
|
188
|
+
expect(() => expectNoMashedSeams(preFix)).toThrow()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('single-worker / agent status card survives the collapse too (#3666)', () => {
|
|
193
|
+
// Both are pinned by status-pin.ts ("the per-turn activity/status message and
|
|
194
|
+
// the ๐ Worker background-worker message"), so both must collapse cleanly.
|
|
195
|
+
it('step bullets stay separated in the collapsed preview', () => {
|
|
196
|
+
const body = renderActivityFeed(['Reading gateway.ts', 'Searching memory', 'Running tests'])!
|
|
197
|
+
expectNoMashedSeams(body)
|
|
198
|
+
expect(collapsePreview(body)).not.toContain('gateway.tsSearching')
|
|
199
|
+
expect(collapsePreview(body)).toContain(`Reading gateway.ts${NB}โ Searching memory`)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('the hand-rolled `startingโฆ` seam carries the separator too', () => {
|
|
203
|
+
// renderWorkerActivity appends this placeholder with its OWN ` \n` rather
|
|
204
|
+
// than through stackCardLines (worker-activity-feed.ts), so it is the one
|
|
205
|
+
// boundary on a pinned card that a collapseSafe flag alone does NOT reach.
|
|
206
|
+
// Without the explicit separator there it reads `โฆ 0 toolsstartingโฆ`.
|
|
207
|
+
const body = renderWorkerActivity({
|
|
208
|
+
workerId: 'w1',
|
|
209
|
+
description: 'lone worker',
|
|
210
|
+
latestSummary: '',
|
|
211
|
+
narrativeLines: [],
|
|
212
|
+
elapsedMs: 1_000,
|
|
213
|
+
toolCount: 0,
|
|
214
|
+
state: 'running',
|
|
215
|
+
})
|
|
216
|
+
expect(body).toContain('starting')
|
|
217
|
+
expectNoMashedSeams(body)
|
|
218
|
+
const collapsed = collapsePreview(body)
|
|
219
|
+
expect(collapsed).not.toContain('toolsstarting')
|
|
220
|
+
expect(collapsed).toContain(`0 tools${NB}starting`)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('the nested child block stays separated even though its indent is ASCII (#3668)', () => {
|
|
224
|
+
// NESTED_PREFIX is three ASCII SPACES, which Telegram's parser drops (see
|
|
225
|
+
// previewLines), so on this surface the collapse separator is the ONLY
|
|
226
|
+
// thing holding the parent->child seam apart. Unlike the combined card,
|
|
227
|
+
// there is no U+2800 indent to mask a regression here.
|
|
228
|
+
const body = renderActivityFeedWithNested(
|
|
229
|
+
['Reading gateway.ts'],
|
|
230
|
+
['Searching memory', 'Running tests'],
|
|
231
|
+
)!
|
|
232
|
+
expectNoMashedSeams(body)
|
|
233
|
+
const collapsed = collapsePreview(body)
|
|
234
|
+
expect(collapsed).not.toContain('gateway.tsโณ')
|
|
235
|
+
expect(collapsed).toContain(`gateway.ts${NB}โณ`)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('the char-budget backstop keeps the separator on every line it emits', () => {
|
|
239
|
+
// fitCardToBudget is a SECOND, independent stacking path (the wire-limit
|
|
240
|
+
// safety net). A collapseSafe flag set only on renderStatusCard's happy
|
|
241
|
+
// path would leave every card that trips the backstop mashing.
|
|
242
|
+
const steps = Array.from({ length: 400 }, (_, i) => `${'x'.repeat(STATUS_LINE_MAX)} ${i}`)
|
|
243
|
+
const opts = {
|
|
244
|
+
header: {
|
|
245
|
+
emoji: '๐ค',
|
|
246
|
+
label: 'Agent',
|
|
247
|
+
description: 'a very long turn',
|
|
248
|
+
elapsedMs: 1_000,
|
|
249
|
+
toolCount: steps.length,
|
|
250
|
+
state: 'running' as const,
|
|
251
|
+
},
|
|
252
|
+
steps,
|
|
253
|
+
historyWindow: steps.length,
|
|
254
|
+
}
|
|
255
|
+
// Precondition: without the backstop this card would blow the wire budget,
|
|
256
|
+
// so the assertions below really are running on the backstop's output.
|
|
257
|
+
expect(stackCardLines(steps).length).toBeGreaterThan(STATUS_CARD_CHAR_BUDGET)
|
|
258
|
+
const body = renderStatusCard(opts)!
|
|
259
|
+
expect(body.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
260
|
+
expect(body).toContain('earlier') // the `+N earlierโฆ` marker fitCardToBudget adds
|
|
261
|
+
expectNoMashedSeams(body)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('every card size across the budget boundary keeps its seams separated', () => {
|
|
265
|
+
// fitCardToBudget has a THIRD stack site: the last-resort return taken when
|
|
266
|
+
// even a single bullet will not fit. escapeStepLine clips every bullet to
|
|
267
|
+
// STATUS_LINE_MAX (200), so that branch is only reachable once the HEADER
|
|
268
|
+
// alone nearly fills the budget โ which is exactly why sweeping the header
|
|
269
|
+
// size across the boundary, rather than naming one magic length, is what
|
|
270
|
+
// walks the card through all three sites (happy path, shrink loop,
|
|
271
|
+
// last resort) without hardcoding the arithmetic of any of them.
|
|
272
|
+
for (const descLen of [
|
|
273
|
+
64,
|
|
274
|
+
STATUS_CARD_CHAR_BUDGET - 4 * STATUS_LINE_MAX,
|
|
275
|
+
STATUS_CARD_CHAR_BUDGET - STATUS_LINE_MAX,
|
|
276
|
+
STATUS_CARD_CHAR_BUDGET - 64,
|
|
277
|
+
STATUS_CARD_CHAR_BUDGET - 48,
|
|
278
|
+
STATUS_CARD_CHAR_BUDGET,
|
|
279
|
+
STATUS_CARD_CHAR_BUDGET + 4096,
|
|
280
|
+
]) {
|
|
281
|
+
const body = renderStatusCard({
|
|
282
|
+
header: {
|
|
283
|
+
emoji: '๐ค',
|
|
284
|
+
label: 'Agent',
|
|
285
|
+
description: 'y'.repeat(descLen),
|
|
286
|
+
elapsedMs: 1_000,
|
|
287
|
+
toolCount: 3,
|
|
288
|
+
state: 'running' as const,
|
|
289
|
+
},
|
|
290
|
+
steps: ['only step'],
|
|
291
|
+
})!
|
|
292
|
+
expectNoMashedSeams(body)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('stackCardLines collapseSafe is opt-in and structure-preserving', () => {
|
|
298
|
+
it('default output is byte-identical to the pre-#3666 join', () => {
|
|
299
|
+
expect(stackCardLines(['a', 'b', 'c'])).toBe('a \nb \nc')
|
|
300
|
+
expect(stackCardLines(['a', '', 'b'])).toBe('a\n\nb')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('collapseSafe appends the separator only at hard-break seams', () => {
|
|
304
|
+
expect(stackCardLines(['a', 'b', 'c'], { collapseSafe: true })).toBe(
|
|
305
|
+
`a${NB} \nb${NB} \nc`,
|
|
306
|
+
)
|
|
307
|
+
// Never on the last line โ nothing follows it to collide with.
|
|
308
|
+
expect(stackCardLines(['a'], { collapseSafe: true })).toBe('a')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('a blank-line paragraph gap keeps its plain newline AND gets a separator', () => {
|
|
312
|
+
// The blank entry is the EMPTY STRING: it contributes no character to a
|
|
313
|
+
// collapsed render, so a gap seam mashes exactly like a hard-break seam
|
|
314
|
+
// unless the content line before it carries the separator.
|
|
315
|
+
expect(stackCardLines(['a', '', 'b'], { collapseSafe: true })).toBe(`a${NB}\n\nb`)
|
|
316
|
+
// โฆbut a blank CURRENT line never gets one: a U+00A0 on the blank entry
|
|
317
|
+
// would turn the invisible gap into a VISIBLE spacer paragraph in the feed.
|
|
318
|
+
expect(stackCardLines(['', 'b'], { collapseSafe: true })).toBe('\nb')
|
|
319
|
+
expect(stackCardLines(['a', ''], { collapseSafe: true })).toBe(`a${NB}\n`)
|
|
320
|
+
// And the property holds across the gap, where it did not before.
|
|
321
|
+
expectNoMashedSeams(stackCardLines(['alpha', '', 'beta'], { collapseSafe: true }))
|
|
322
|
+
expect(() => expectNoMashedSeams(stackCardLines(['alpha', '', 'beta']))).toThrow()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('collapseSafe is idempotent โ re-stacking adds no second separator', () => {
|
|
326
|
+
// stackCardLines already promised "never accumulate spaces on a re-run";
|
|
327
|
+
// the separator has to honour the same invariant or a body that is split
|
|
328
|
+
// and re-joined grows one character per line per pass.
|
|
329
|
+
const once = stackCardLines(['a', 'b', 'c'], { collapseSafe: true })
|
|
330
|
+
expect(stackCardLines(once.split(/ {2}\n/), { collapseSafe: true })).toBe(once)
|
|
331
|
+
expect(stackCardLines(once.split('\n'), { collapseSafe: true })).toBe(once)
|
|
332
|
+
// A doubled separator collapses back to exactly one.
|
|
333
|
+
expect(stackCardLines([`a${NB}${NB}`, 'b'], { collapseSafe: true })).toBe(`a${NB} \nb`)
|
|
334
|
+
// โฆincluding across a paragraph gap, whose separator is appended by a
|
|
335
|
+
// different branch and would otherwise accumulate on its own.
|
|
336
|
+
const gap = stackCardLines(['a', '', 'b'], { collapseSafe: true })
|
|
337
|
+
expect(stackCardLines(gap.split('\n'), { collapseSafe: true })).toBe(gap)
|
|
338
|
+
// Default mode is untouched by the separator strip.
|
|
339
|
+
expect(stackCardLines([`a${NB}`, 'b'])).toBe(`a${NB} \nb`)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('the separator survives the hard break rather than replacing it', () => {
|
|
343
|
+
// Load-bearing: the two spaces must stay IMMEDIATELY before the `\n` or the
|
|
344
|
+
// GFM hard break stops parsing and the card collapses in the FEED too.
|
|
345
|
+
const out = stackCardLines(['a', 'b'], { collapseSafe: true })
|
|
346
|
+
expect(out).toMatch(/ {2}\n/)
|
|
347
|
+
expect(out).not.toMatch(/(?<! {2})\n/)
|
|
348
|
+
// The separator is not ASCII whitespace, so the trailing-space strip that
|
|
349
|
+
// guards against accumulation cannot eat it.
|
|
350
|
+
expect(NB).not.toMatch(/[ \t\r]/)
|
|
351
|
+
// โฆand it is non-empty. The strip loop treats an empty separator as a
|
|
352
|
+
// no-op rather than spinning, but an empty separator would also silently
|
|
353
|
+
// reinstate #3666 on every pinned card, so pin the contract here.
|
|
354
|
+
expect(NB.length).toBeGreaterThan(0)
|
|
355
|
+
})
|
|
356
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-pin-api โ the two invariants the extracted pin API enforces (#3664).
|
|
3
|
+
*
|
|
4
|
+
* Both are about the same failure shape: the pin state machine reads "the call
|
|
5
|
+
* did not throw" as "the pin/unpin LANDED". Anything that resolves
|
|
6
|
+
* success-shaped without reaching Telegram therefore corrupts pin state โ a
|
|
7
|
+
* claimed-but-never-painted pin, or (worse, Defect B's shape) a dropped claim
|
|
8
|
+
* plus a deleted durable row for a message that is still pinned.
|
|
9
|
+
*
|
|
10
|
+
* The assertions below are OUTCOMES read off `reconcilePin`'s returned state
|
|
11
|
+
* (does the gateway still hold a record of the pinned message?), not "was some
|
|
12
|
+
* function called".
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
createStatusPinApi,
|
|
17
|
+
assertLanded,
|
|
18
|
+
BotNotReadyError,
|
|
19
|
+
SendGateShedError,
|
|
20
|
+
type PinCapableBot,
|
|
21
|
+
} from '../gateway/status-pin-api.js'
|
|
22
|
+
import { SEND_GATE_SHED } from '../send-gate.js'
|
|
23
|
+
import { reconcilePin } from '../status-pin-driver.js'
|
|
24
|
+
|
|
25
|
+
/** A bot whose API always succeeds โ so any failure below comes from the gate
|
|
26
|
+
* seam, never from Telegram. */
|
|
27
|
+
const liveBot = (): PinCapableBot => ({
|
|
28
|
+
api: {
|
|
29
|
+
pinChatMessage: async () => true,
|
|
30
|
+
unpinChatMessage: async () => true,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** `robustApiCall` stand-in that behaves as the send gate does on a SHED:
|
|
35
|
+
* it RESOLVES the sentinel instead of throwing (send-gate.ts:140). */
|
|
36
|
+
const shedRobust = async () => SEND_GATE_SHED as unknown
|
|
37
|
+
|
|
38
|
+
describe('assertLanded โ a shed send must not look like a landed one (#3664)', () => {
|
|
39
|
+
it('a shed unpin does NOT drop the claim โ the message is still pinned', async () => {
|
|
40
|
+
const api = createStatusPinApi(liveBot, shedRobust)
|
|
41
|
+
const next = await reconcilePin({
|
|
42
|
+
api,
|
|
43
|
+
chatId: '-100123',
|
|
44
|
+
prevState: { messageId: 715 },
|
|
45
|
+
desired: { pinned: false },
|
|
46
|
+
onError: () => {},
|
|
47
|
+
})
|
|
48
|
+
// Without assertLanded the shed resolves success-shaped, reconcilePin
|
|
49
|
+
// returns null, and reconcileAndPersistStatusPin then DELETES the durable
|
|
50
|
+
// row for a message that is provably still pinned โ Defect B, reopened.
|
|
51
|
+
expect(next).toEqual({ messageId: 715 })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('a shed pin does not claim the message', async () => {
|
|
55
|
+
const api = createStatusPinApi(liveBot, shedRobust)
|
|
56
|
+
const next = await reconcilePin({
|
|
57
|
+
api,
|
|
58
|
+
chatId: '-100123',
|
|
59
|
+
prevState: null,
|
|
60
|
+
desired: { pinned: true, messageId: 715 },
|
|
61
|
+
onError: () => {},
|
|
62
|
+
})
|
|
63
|
+
// Claiming it would leave the gateway believing a pin exists that never
|
|
64
|
+
// painted, and `decidePinAction` would return `noop` forever.
|
|
65
|
+
expect(next).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('a landed call is passed through untouched', async () => {
|
|
69
|
+
const seen: unknown[] = []
|
|
70
|
+
const api = createStatusPinApi(liveBot, async (fn) => {
|
|
71
|
+
const r = await fn()
|
|
72
|
+
seen.push(r)
|
|
73
|
+
return r
|
|
74
|
+
})
|
|
75
|
+
await expect(api.unpinChatMessage('-100123', 715)).resolves.toBe(true)
|
|
76
|
+
expect(seen).toEqual([true])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does NOT treat a benign undefined as a shed', async () => {
|
|
80
|
+
// The gate resolves `undefined` for a no-op drop and the retry policy for a
|
|
81
|
+
// swallowed benign 400. Conflating either with a shed is the ambiguity
|
|
82
|
+
// SEND_GATE_SHED exists to remove โ and would make every benign unpin
|
|
83
|
+
// retain a claim forever.
|
|
84
|
+
const api = createStatusPinApi(liveBot, async () => undefined)
|
|
85
|
+
await expect(api.unpinChatMessage('-100123', 715)).resolves.toBeUndefined()
|
|
86
|
+
|
|
87
|
+
const next = await reconcilePin({
|
|
88
|
+
api,
|
|
89
|
+
chatId: '-100123',
|
|
90
|
+
prevState: { messageId: 715 },
|
|
91
|
+
desired: { pinned: false },
|
|
92
|
+
onError: () => {},
|
|
93
|
+
})
|
|
94
|
+
expect(next).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('throws a named SendGateShedError, and only on the sentinel', () => {
|
|
98
|
+
expect(() => assertLanded(SEND_GATE_SHED, 'status-pin.unpin')).toThrow(SendGateShedError)
|
|
99
|
+
expect(() => assertLanded(SEND_GATE_SHED, 'status-pin.unpin')).toThrow('STATUS_PIN_SEND_SHED')
|
|
100
|
+
expect(assertLanded(undefined, 'status-pin.unpin')).toBeUndefined()
|
|
101
|
+
expect(assertLanded(null, 'status-pin.unpin')).toBeNull()
|
|
102
|
+
expect(assertLanded(false, 'status-pin.unpin')).toBe(false)
|
|
103
|
+
expect(assertLanded(Symbol('other'), 'status-pin.unpin')).toBeTypeOf('symbol')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('assertBotReady โ named backstop for a pre-ready caller (#3664)', () => {
|
|
108
|
+
const passthrough = async (fn: () => Promise<unknown>) => fn()
|
|
109
|
+
|
|
110
|
+
it('throws STATUS_PIN_BOT_NOT_READY instead of dereferencing undefined', async () => {
|
|
111
|
+
const api = createStatusPinApi(() => undefined, passthrough)
|
|
112
|
+
// The month-long symptom was `undefined is not an object (evaluating
|
|
113
|
+
// 'lockedBot.api')`, which read like a Telegram fault.
|
|
114
|
+
await expect(api.unpinChatMessage('-100123', 715)).rejects.toThrow(BotNotReadyError)
|
|
115
|
+
await expect(api.pinChatMessage('-100123', 715)).rejects.toThrow(
|
|
116
|
+
/STATUS_PIN_BOT_NOT_READY: status-pin\.pin/,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('reads the bot LAZILY, so a late assignment works', async () => {
|
|
121
|
+
let bot: PinCapableBot | undefined
|
|
122
|
+
const api = createStatusPinApi(() => bot, passthrough)
|
|
123
|
+
await expect(api.unpinChatMessage('-100123', 715)).rejects.toThrow(BotNotReadyError)
|
|
124
|
+
bot = liveBot()
|
|
125
|
+
await expect(api.unpinChatMessage('-100123', 715)).resolves.toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('a pre-ready unpin failure RETAINS the claim (it never reached Telegram)', async () => {
|
|
129
|
+
const api = createStatusPinApi(() => undefined, passthrough)
|
|
130
|
+
const next = await reconcilePin({
|
|
131
|
+
api,
|
|
132
|
+
chatId: '-100123',
|
|
133
|
+
prevState: { messageId: 715 },
|
|
134
|
+
desired: { pinned: false },
|
|
135
|
+
onError: () => {},
|
|
136
|
+
})
|
|
137
|
+
expect(next).toEqual({ messageId: 715 })
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('createStatusPinApi โ call shape is preserved by the extraction', () => {
|
|
142
|
+
it('tags each call with the chat id and verb the retry policy expects', async () => {
|
|
143
|
+
const opts: Record<string, unknown>[] = []
|
|
144
|
+
const api = createStatusPinApi(liveBot, async (fn, o) => {
|
|
145
|
+
opts.push(o)
|
|
146
|
+
return fn()
|
|
147
|
+
})
|
|
148
|
+
await api.pinChatMessage(-100123, 715, { disable_notification: true })
|
|
149
|
+
await api.unpinChatMessage(-100123, 715)
|
|
150
|
+
expect(opts).toEqual([
|
|
151
|
+
{ chat_id: '-100123', verb: 'status-pin.pin' },
|
|
152
|
+
{ chat_id: '-100123', verb: 'status-pin.unpin' },
|
|
153
|
+
])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('forwards the pin opts through to the Bot API', async () => {
|
|
157
|
+
const calls: unknown[][] = []
|
|
158
|
+
const bot: PinCapableBot = {
|
|
159
|
+
api: {
|
|
160
|
+
pinChatMessage: async (...args) => {
|
|
161
|
+
calls.push(args)
|
|
162
|
+
return true
|
|
163
|
+
},
|
|
164
|
+
unpinChatMessage: async (...args) => {
|
|
165
|
+
calls.push(args)
|
|
166
|
+
return true
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const api = createStatusPinApi(() => bot, async (fn) => fn())
|
|
171
|
+
await api.pinChatMessage('-100123', 715, { disable_notification: true })
|
|
172
|
+
await api.unpinChatMessage('-100123', 715)
|
|
173
|
+
expect(calls).toEqual([
|
|
174
|
+
['-100123', 715, { disable_notification: true }],
|
|
175
|
+
['-100123', 715],
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
})
|