switchroom 0.20.3 → 0.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/cli/switchroom.js +10256 -1233
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -3
  4. package/skills/switchroom-release/SKILL.md +3 -2
  5. package/telegram-plugin/dist/gateway/gateway.js +452 -99
  6. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  7. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  8. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  9. package/telegram-plugin/gateway/gateway.ts +130 -104
  10. package/telegram-plugin/gateway/narrative-lane.ts +19 -0
  11. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  12. package/telegram-plugin/gateway/stream-render.ts +67 -12
  13. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  14. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  15. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  16. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  17. package/telegram-plugin/registry/turns-schema.ts +26 -0
  18. package/telegram-plugin/silence-poke.ts +80 -0
  19. package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
  20. package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
  21. package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
  22. package/telegram-plugin/tests/progress-cap.test.ts +182 -0
  23. package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
  24. package/telegram-plugin/tests/progress-update.test.ts +108 -12
  25. package/telegram-plugin/tests/silence-poke-card-render.test.ts +300 -0
  26. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
  27. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  28. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Per-`message_id` cosmetic fair-share (#4300).
3
+ *
4
+ * The bug: `cosmeticPerChatMaxPerWindow` (6/60s) is ONE bucket shared across
5
+ * every cosmetic surface in a chat — the primary activity card, a worker /
6
+ * sub-agent card, and answer-stream draft edits. Under intra-cosmetic
7
+ * contention (two live cards) — and especially once a 429 tightens that bucket
8
+ * — the surface the user is actually watching (the primary activity card) could
9
+ * be starved well past 60s between frames while the OTHER surface kept
10
+ * repainting. The fix carves a small AIMD-immune per-message floor OUT OF the
11
+ * same pool (no new wire rate, so 429 risk is unchanged) so no cosmetic surface
12
+ * can starve another, and a watched card keeps a minimum cadence under flood.
13
+ *
14
+ * These tests assert OUTCOMES (frames that actually reached the API within a
15
+ * window), not code paths, and pin the behaviour against the kill-switch so a
16
+ * revert of the core change fails at least one of them.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { createEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS } from '../edit-flood-fuse.js'
20
+ import type { Clock } from '../send-gate.js'
21
+
22
+ const CHAT = '5005'
23
+ const PRIMARY = 1 // the activity card the user watches
24
+ const WORKER = 2 // a second live cosmetic surface (sub-agent card / draft)
25
+
26
+ const D = EDIT_FLOOD_FUSE_DEFAULTS
27
+
28
+ class FakeClock implements Clock {
29
+ private cur = 0
30
+ private seq = 0
31
+ private timers: { at: number; id: number; resolve: () => void }[] = []
32
+
33
+ now(): number { return this.cur }
34
+
35
+ sleep(ms: number): Promise<void> {
36
+ return new Promise<void>((resolve) => {
37
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
38
+ })
39
+ }
40
+
41
+ async advance(ms: number): Promise<void> {
42
+ const target = this.cur + ms
43
+ for (;;) {
44
+ await flush()
45
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
46
+ if (due.length === 0) break
47
+ const t = due[0]!
48
+ this.timers = this.timers.filter((x) => x !== t)
49
+ this.cur = t.at
50
+ t.resolve()
51
+ await flush()
52
+ }
53
+ this.cur = target
54
+ await flush()
55
+ }
56
+ }
57
+
58
+ function flush(): Promise<void> {
59
+ return new Promise((r) => setImmediate(r))
60
+ }
61
+
62
+ const flood = () => Object.assign(
63
+ new Error('Too Many Requests: retry after 3'),
64
+ { error_code: 429, parameters: { retry_after: 3 } },
65
+ )
66
+
67
+ /**
68
+ * Drive `n` observed 429s so the ceilings tighten by `n` AIMD levels. The
69
+ * probes go to a SEPARATE chat: tightening is global, but a probe still charges
70
+ * the target chat's shared total window, and we don't want those synthetic
71
+ * sends to eat into CHAT's tightened cosmetic-total budget under test.
72
+ */
73
+ async function tighten(fuse: ReturnType<typeof createEditFloodFuse>, n: number): Promise<void> {
74
+ for (let i = 0; i < n; i++) {
75
+ await fuse.apply('sendMessage', { chat_id: '9009', text: 'probe' }, async () => { throw flood() })
76
+ .then(() => { /* unreachable */ }, () => { /* the 429 is re-thrown; swallow */ })
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Run a contention scenario: WORKER repaints densely (every `workerEveryMs`)
82
+ * and PRIMARY repaints at a heartbeat cadence (every `primaryEveryMs`), both
83
+ * for `spanMs`. Returns how many frames of each landed strictly inside the
84
+ * first `perChatWindowMs` window.
85
+ */
86
+ async function contend(
87
+ fuse: ReturnType<typeof createEditFloodFuse>, clock: FakeClock,
88
+ opts: { spanMs: number; workerEveryMs: number; primaryEveryMs: number },
89
+ ): Promise<{ primary: number; worker: number }> {
90
+ const landed: { primary: number; worker: number } = { primary: 0, worker: 0 }
91
+ const inflight: Promise<unknown>[] = []
92
+ let nextWorker = 0
93
+ let nextPrimary = 3_000 // let the worker grab the pool first, as in the real stall
94
+ const step = 500
95
+ for (let t = 0; t <= opts.spanMs; t += step) {
96
+ if (t >= nextWorker) {
97
+ inflight.push(fuse.apply(
98
+ 'editMessageText', { chat_id: CHAT, message_id: WORKER, text: `w${t}` },
99
+ async () => { if (clock.now() < D.perChatWindowMs) landed.worker++; return true },
100
+ ))
101
+ nextWorker += opts.workerEveryMs
102
+ }
103
+ if (t >= nextPrimary) {
104
+ inflight.push(fuse.apply(
105
+ 'editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `p${t}` },
106
+ async () => { if (clock.now() < D.perChatWindowMs) landed.primary++; return true },
107
+ ))
108
+ nextPrimary += opts.primaryEveryMs
109
+ }
110
+ await clock.advance(step)
111
+ }
112
+ // Drain every still-deferred frame so no promise is left dangling.
113
+ await clock.advance(D.maxDeferMs + 1_000)
114
+ await Promise.all(inflight)
115
+ return landed
116
+ }
117
+
118
+ describe('#4300 cosmetic fair-share — a watched card is not starved by a second surface', () => {
119
+ it('(a) under flood tightening the primary card keeps its per-message floor while a worker card repaints', async () => {
120
+ // One 429 → AIMD level 1: the shared cosmetic pool ceiling(6) shrinks to 3,
121
+ // which two live cosmetic surfaces would otherwise fight over.
122
+ const clockOn = new FakeClock()
123
+ const fuseOn = createEditFloodFuse({ clock: clockOn })
124
+ await tighten(fuseOn, 1)
125
+ expect(fuseOn.stats().tightenLevel).toBeGreaterThanOrEqual(1)
126
+ const on = await contend(fuseOn, clockOn, { spanMs: 58_000, workerEveryMs: 1_000, primaryEveryMs: 6_000 })
127
+
128
+ // The invariant: the watched card still repaints at least its guaranteed
129
+ // floor (cosmeticFloorPerWindow = 2/60s = 1 edit/30s), AIMD-immune, even
130
+ // though the worker is hammering the same pool.
131
+ expect(on.primary).toBeGreaterThanOrEqual(D.cosmeticFloorPerWindow)
132
+ })
133
+
134
+ it('(d) mutation guard — with fair-share OFF the SAME scenario starves the primary below the floor', async () => {
135
+ // Kill-switch OFF is the pre-fix single shared bucket. Reverting the core
136
+ // change is behaviourally identical to this, so if the fix did nothing the
137
+ // (a) assertion above would already hold here — it must NOT.
138
+ const clockOff = new FakeClock()
139
+ const fuseOff = createEditFloodFuse({ clock: clockOff, cosmeticFairShareEnabled: false })
140
+ await tighten(fuseOff, 1)
141
+ const off = await contend(fuseOff, clockOff, { spanMs: 58_000, workerEveryMs: 1_000, primaryEveryMs: 6_000 })
142
+
143
+ // No per-message floor: the dense worker wins the shared pool and the
144
+ // watched card is starved below the floor the fix guarantees.
145
+ expect(off.primary).toBeLessThan(D.cosmeticFloorPerWindow)
146
+ expect(fuseOff.stats().cosmeticFairShareEnabled).toBe(false)
147
+ })
148
+
149
+ it('(b) a heavily-tightened pool still lets a lone watched card repaint ≥ 1 edit / 30s (AIMD floor)', async () => {
150
+ const clock = new FakeClock()
151
+ const fuse = createEditFloodFuse({ clock })
152
+ // Two 429s → level 2: ceiling(6) → 1, so WITHOUT the floor the whole chat's
153
+ // cosmetic budget would be a single frame per 60s.
154
+ await tighten(fuse, 2)
155
+ expect(fuse.stats().tightenLevel).toBeGreaterThanOrEqual(2)
156
+
157
+ const landed: number[] = []
158
+ const inflight: Promise<unknown>[] = []
159
+ // A watched card's heartbeat: an edit every 5s across a 60s window.
160
+ for (let i = 0; i < 12; i++) {
161
+ inflight.push(fuse.apply(
162
+ 'editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `hb${i}` },
163
+ async () => { if (clock.now() < D.perChatWindowMs) landed.push(i); return true },
164
+ ))
165
+ await clock.advance(5_000)
166
+ }
167
+ await clock.advance(1_000)
168
+ const inWindow = landed.length
169
+ await clock.advance(D.maxDeferMs + 1_000)
170
+ await Promise.all(inflight)
171
+
172
+ // ≥ 1 edit / 30s = ≥ cosmeticFloorPerWindow (2) per 60s window, guaranteed
173
+ // no matter how many 429s tighten the pool.
174
+ expect(inWindow).toBeGreaterThanOrEqual(D.cosmeticFloorPerWindow)
175
+ })
176
+
177
+ it('(c) fair-share OFF is the old single shared bucket — two surfaces share exactly cosmeticPerChatMax, byte-for-byte', async () => {
178
+ // No tightening: the pre-fix behaviour is that ALL cosmetic surfaces in a
179
+ // chat draw from one `cosmeticPerChatMaxPerWindow` bucket with no
180
+ // per-message reservation. Two saturating surfaces therefore land, in
181
+ // aggregate, exactly the pool — never more (the fix does not raise the wire
182
+ // rate) and, with the flag off, with no floor keeping either one alive.
183
+ const clock = new FakeClock()
184
+ const fuse = createEditFloodFuse({ clock, cosmeticFairShareEnabled: false })
185
+ let a = 0
186
+ let b = 0
187
+ const inflight: Promise<unknown>[] = []
188
+ for (let t = 0; t < 58_000; t += 500) {
189
+ inflight.push(fuse.apply('editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `a${t}` },
190
+ async () => { if (clock.now() < D.perChatWindowMs) a++; return true }))
191
+ inflight.push(fuse.apply('editMessageText', { chat_id: CHAT, message_id: WORKER, text: `b${t}` },
192
+ async () => { if (clock.now() < D.perChatWindowMs) b++; return true }))
193
+ await clock.advance(500)
194
+ }
195
+ await clock.advance(D.maxDeferMs + 1_000)
196
+ await Promise.all(inflight)
197
+
198
+ // The single shared bucket: aggregate cosmetic frames == the pool, and the
199
+ // fair-share machinery is provably not in play.
200
+ expect(a + b).toBeLessThanOrEqual(D.cosmeticPerChatMaxPerWindow)
201
+ expect(a + b).toBeGreaterThan(0)
202
+ expect(fuse.stats().cosmeticFairShareEnabled).toBe(false)
203
+ })
204
+
205
+ it('raises a visible `throttled` signal when a cosmetic edit is deferred past throttleNoticeMs', async () => {
206
+ const clock = new FakeClock()
207
+ const actions: string[] = []
208
+ // Saturate the per-chat cosmetic pool with a DIFFERENT card so the target
209
+ // edit has to wait; a long defer window lets it cross the notice threshold.
210
+ const fuse = createEditFloodFuse({
211
+ clock,
212
+ cosmeticPerChatMaxPerWindow: 1, cosmeticFloorPerWindow: 0,
213
+ perChatWindowMs: 600_000, maxDeferMs: 120_000, throttleNoticeMs: 45_000,
214
+ onTrip: (i) => { actions.push(i.action) },
215
+ })
216
+ // Burn the pool.
217
+ await fuse.apply('editMessageText', { chat_id: CHAT, message_id: WORKER, text: 'hog' },
218
+ async () => true)
219
+ // The watched card's frame cannot get in; it sits deferred.
220
+ const stuck = fuse.apply('editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: 'watched' },
221
+ async () => true)
222
+ await clock.advance(46_000)
223
+ expect(fuse.stats().throttled).toBeGreaterThan(0)
224
+ expect(actions).toContain('throttled')
225
+ // Drain.
226
+ await clock.advance(120_000)
227
+ await stuck
228
+ })
229
+ })
@@ -179,6 +179,48 @@ describe('mayOpenActivityCard — lever 1 exception: post-answer sub-agent liven
179
179
  })
180
180
  })
181
181
 
182
+ describe('mayOpenActivityCard — lever 1 exception: post-substantive MAIN-agent reopen', () => {
183
+ // The foreground sibling of the sub-agent exemption above: a turn that
184
+ // delivered a substantive answer early and kept doing tool work sets
185
+ // `postAnswerMainActivity=true` (via decideFeedReopen().liftLeverOne) so a
186
+ // fresh activity card can open below the reply. Same 'tool'-only scoping.
187
+
188
+ it('post-answer MAIN-agent tool activity DOES open a card (tool + postAnswerMainActivity)', () => {
189
+ expect(
190
+ mayOpenActivityCard({
191
+ producer: 'tool',
192
+ finalAnswerEverDelivered: true,
193
+ labeledToolCount: 4,
194
+ postAnswerMainActivity: true,
195
+ }),
196
+ ).toBe(true)
197
+ })
198
+
199
+ it('without the signal Lever 1 stays active (no postAnswerMainActivity → blocked)', () => {
200
+ expect(
201
+ mayOpenActivityCard({
202
+ producer: 'tool',
203
+ finalAnswerEverDelivered: true,
204
+ labeledToolCount: 4,
205
+ postAnswerMainActivity: false,
206
+ }),
207
+ ).toBe(false)
208
+ })
209
+
210
+ it('only the tool producer is exempted (liveness/narrative stay blocked with the signal)', () => {
211
+ for (const producer of ['liveness', 'narrative'] as const) {
212
+ expect(
213
+ mayOpenActivityCard({
214
+ producer,
215
+ finalAnswerEverDelivered: true,
216
+ labeledToolCount: 4,
217
+ postAnswerMainActivity: true,
218
+ }),
219
+ ).toBe(false)
220
+ }
221
+ })
222
+ })
223
+
182
224
  describe('shouldEarlyOpenLiveness — the early-open WHEN gate (enqueue + heartbeat)', () => {
183
225
  // The minimal "Working…" placeholder is due to open for a 0-label turn once it
184
226
  // has been alive past the threshold and NO card is open yet. Both the
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import {
4
+ SUBSTANTIVE_REOPEN_MIN_LABELS,
4
5
  decideFeedReopen,
5
6
  shouldReopenFeedAfterAck,
6
7
  } from '../gateway/feed-reopen-gate.js'
@@ -131,3 +132,116 @@ describe('decideFeedReopen — tool_label branch outcome for a delivered turn',
131
132
  expect(outcome.reset).toBeUndefined()
132
133
  })
133
134
  })
135
+
136
+ /**
137
+ * Post-SUBSTANTIVE feed reopen — a turn that delivered a real answer EARLY and
138
+ * then kept doing tool work for minutes had its live feed go dark for the rest
139
+ * of the turn. It reopens once >= SUBSTANTIVE_REOPEN_MIN_LABELS post-answer
140
+ * labels arrive, WITHOUT clearing finalAnswerDelivered (which would trip the
141
+ * turn-end re-prompt → duplicate answer). Gated by
142
+ * `reopenAfterSubstantiveEnabled` (SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE).
143
+ */
144
+ describe('post-substantive feed reopen', () => {
145
+ const substantiveBase = {
146
+ finalAnswerDelivered: true,
147
+ finalAnswerSubstantive: true,
148
+ enabled: true,
149
+ reopenAfterSubstantiveEnabled: true,
150
+ }
151
+
152
+ it('reopens once >= SUBSTANTIVE_REOPEN_MIN_LABELS post-answer labels have arrived', () => {
153
+ // The bug: with the flag ON but the counter at threshold, the old gate
154
+ // hard-returned false. Now it reopens.
155
+ expect(
156
+ shouldReopenFeedAfterAck({
157
+ ...substantiveBase,
158
+ postSubstantiveToolLabelCount: SUBSTANTIVE_REOPEN_MIN_LABELS,
159
+ }),
160
+ ).toBe(true)
161
+ // And it keeps returning true for further labels (a long multi-phase turn).
162
+ expect(
163
+ shouldReopenFeedAfterAck({
164
+ ...substantiveBase,
165
+ postSubstantiveToolLabelCount: SUBSTANTIVE_REOPEN_MIN_LABELS + 5,
166
+ }),
167
+ ).toBe(true)
168
+ })
169
+
170
+ it('does NOT reopen before the threshold (0 or 1 post-answer labels → no flap)', () => {
171
+ // A genuinely-final single-reply turn with a stray housekeeping tool must
172
+ // not flap the card.
173
+ for (const n of [0, 1]) {
174
+ expect(
175
+ shouldReopenFeedAfterAck({
176
+ ...substantiveBase,
177
+ postSubstantiveToolLabelCount: n,
178
+ }),
179
+ ).toBe(false)
180
+ }
181
+ // The threshold is exactly 2 (not 1) by design.
182
+ expect(SUBSTANTIVE_REOPEN_MIN_LABELS).toBe(2)
183
+ })
184
+
185
+ it('flag OFF → never reopens after a substantive final, even past the threshold (legacy)', () => {
186
+ expect(
187
+ shouldReopenFeedAfterAck({
188
+ finalAnswerDelivered: true,
189
+ finalAnswerSubstantive: true,
190
+ enabled: true,
191
+ reopenAfterSubstantiveEnabled: false,
192
+ postSubstantiveToolLabelCount: SUBSTANTIVE_REOPEN_MIN_LABELS + 10,
193
+ }),
194
+ ).toBe(false)
195
+ // Omitting the flag entirely is also legacy (default-off at the pure gate).
196
+ expect(
197
+ shouldReopenFeedAfterAck({
198
+ finalAnswerDelivered: true,
199
+ finalAnswerSubstantive: true,
200
+ enabled: true,
201
+ postSubstantiveToolLabelCount: SUBSTANTIVE_REOPEN_MIN_LABELS + 10,
202
+ }),
203
+ ).toBe(false)
204
+ })
205
+
206
+ it('decideFeedReopen: at threshold → reopen with liftLeverOne and NO reset (finalAnswerDelivered stays true)', () => {
207
+ const outcome = decideFeedReopen({
208
+ ...substantiveBase,
209
+ postSubstantiveToolLabelCount: SUBSTANTIVE_REOPEN_MIN_LABELS,
210
+ })
211
+ expect(outcome.dropLabel).toBe(false)
212
+ // Crucially NO reset: finalAnswerDelivered must stay true so the turn-end
213
+ // silent-end re-prompt does not fire → no duplicate answer.
214
+ expect(outcome.reset).toBeUndefined()
215
+ // Lever 1 must be lifted so the fresh card opens below the delivered reply.
216
+ expect(outcome.liftLeverOne).toBe(true)
217
+ })
218
+
219
+ it('decideFeedReopen: below threshold → drops the label (feed stays gated, no flap)', () => {
220
+ const outcome = decideFeedReopen({
221
+ ...substantiveBase,
222
+ postSubstantiveToolLabelCount: 1,
223
+ })
224
+ expect(outcome.dropLabel).toBe(true)
225
+ expect(outcome.reset).toBeUndefined()
226
+ expect(outcome.liftLeverOne).toBeUndefined()
227
+ })
228
+
229
+ it('ack reopen is unchanged: reset clears finalAnswerDelivered and does NOT lift lever 1', () => {
230
+ // The ack path (non-substantive) is orthogonal — the new flag/counter must
231
+ // not perturb it.
232
+ const outcome = decideFeedReopen({
233
+ finalAnswerDelivered: true,
234
+ finalAnswerSubstantive: false,
235
+ enabled: true,
236
+ reopenAfterSubstantiveEnabled: true,
237
+ postSubstantiveToolLabelCount: 99,
238
+ })
239
+ expect(outcome.dropLabel).toBe(false)
240
+ expect(outcome.reset).toEqual({
241
+ finalAnswerDelivered: false,
242
+ activityMessageId: null,
243
+ activityLastSentRender: null,
244
+ })
245
+ expect(outcome.liftLeverOne).toBeUndefined()
246
+ })
247
+ })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Integration test for the `progress_update` attention-cap RESERVATION path
3
+ * (`gateway/progress-fallback-cap.ts` `reserveProgressSlot` /
4
+ * `sendWithProgressCap`).
5
+ *
6
+ * These are the REAL functions the gateway's `executeProgressUpdate` calls —
7
+ * not a mirror re-implementation. `executeProgressUpdate` wires
8
+ * `sendWithProgressCap({ key, now, turnStart, turnCount }, () => robustApiCall(...))`
9
+ * and returns `turn_limit` on `{ capped: true }`, so an ordering regression
10
+ * INSIDE the reserve/release/cap logic is caught here. (The surrounding
11
+ * executeProgressUpdate — truncation, the 20s floor, secret scrub — is covered
12
+ * by `progress-update.test.ts`; isolating the whole async function would drag in
13
+ * the entire gateway module, so the counter-ordering + cap logic is the seam
14
+ * bound directly.)
15
+ *
16
+ * What it proves:
17
+ * (a) the turn-less fallback path caps at 5 and refuses the 6th;
18
+ * (b) a thrown send consumes NO slot on either path (release-on-throw);
19
+ * (c) N truly-concurrent same-key sends never exceed the cap (Low 1 — the
20
+ * reservation happens BEFORE the await, so a concurrent caller sees it).
21
+ */
22
+ import { describe, it, expect, beforeEach } from 'bun:test'
23
+ import {
24
+ reserveProgressSlot,
25
+ sendWithProgressCap,
26
+ _resetProgressFallbackCap,
27
+ PROGRESS_TURN_MAX,
28
+ } from '../gateway/progress-fallback-cap.js'
29
+
30
+ const KEY = 'chat123:_'
31
+
32
+ /** A send that resolves on the next microtask (models the real async send). */
33
+ function asyncSend<T>(value: T, counter?: { n: number }): () => Promise<T> {
34
+ return async () => {
35
+ await Promise.resolve()
36
+ if (counter) counter.n += 1
37
+ return value
38
+ }
39
+ }
40
+
41
+ describe('progress cap reservation (real gateway path)', () => {
42
+ beforeEach(() => {
43
+ _resetProgressFallbackCap()
44
+ })
45
+
46
+ // (a) turn-less path caps at PROGRESS_TURN_MAX, refuses the next.
47
+ it('turn-less path allows exactly PROGRESS_TURN_MAX then caps (a)', async () => {
48
+ const turnCount = new Map<string, number>()
49
+ const now = 1_000
50
+ for (let i = 0; i < PROGRESS_TURN_MAX; i++) {
51
+ const r = await sendWithProgressCap(
52
+ { key: KEY, now, turnStart: undefined, turnCount },
53
+ asyncSend({ message_id: i }),
54
+ )
55
+ expect(r.capped).toBe(false)
56
+ }
57
+ const over = await sendWithProgressCap(
58
+ { key: KEY, now, turnStart: undefined, turnCount },
59
+ asyncSend({ message_id: 99 }),
60
+ )
61
+ expect(over.capped).toBe(true)
62
+ })
63
+
64
+ // (a') turn-scoped path caps too, and does not touch the fallback window.
65
+ it('turn-scoped path caps at PROGRESS_TURN_MAX (a)', async () => {
66
+ const turnCount = new Map<string, number>()
67
+ const deps = { key: KEY, now: 1_000, turnStart: 1_000, turnCount }
68
+ for (let i = 0; i < PROGRESS_TURN_MAX; i++) {
69
+ const r = await sendWithProgressCap(deps, asyncSend({ message_id: i }))
70
+ expect(r.capped).toBe(false)
71
+ }
72
+ expect(turnCount.get(KEY)).toBe(PROGRESS_TURN_MAX)
73
+ const over = await sendWithProgressCap(deps, asyncSend({ message_id: 99 }))
74
+ expect(over.capped).toBe(true)
75
+ // Still exactly at the cap — the refused call did not increment.
76
+ expect(turnCount.get(KEY)).toBe(PROGRESS_TURN_MAX)
77
+ })
78
+
79
+ // (b) a thrown send releases the turn-counter slot.
80
+ it('a thrown send consumes no turn-counter slot (b)', async () => {
81
+ const turnCount = new Map<string, number>([[KEY, 0]])
82
+ const deps = { key: KEY, now: 1_000, turnStart: 1_000, turnCount }
83
+ await expect(
84
+ sendWithProgressCap(deps, async () => {
85
+ throw new Error('telegram 500')
86
+ }),
87
+ ).rejects.toThrow('telegram 500')
88
+ // Slot released → counter back to 0.
89
+ expect(turnCount.get(KEY)).toBe(0)
90
+ // Full budget still available afterwards.
91
+ for (let i = 0; i < PROGRESS_TURN_MAX; i++) {
92
+ const r = await sendWithProgressCap(deps, asyncSend({ message_id: i }))
93
+ expect(r.capped).toBe(false)
94
+ }
95
+ expect(turnCount.get(KEY)).toBe(PROGRESS_TURN_MAX)
96
+ })
97
+
98
+ // (b) a thrown send releases the fallback-window slot.
99
+ it('a thrown send consumes no fallback-window slot (b)', async () => {
100
+ const turnCount = new Map<string, number>()
101
+ const now = 1_000
102
+ // PROGRESS_TURN_MAX throwing sends on the turn-less path.
103
+ for (let i = 0; i < PROGRESS_TURN_MAX; i++) {
104
+ await expect(
105
+ sendWithProgressCap(
106
+ { key: KEY, now, turnStart: undefined, turnCount },
107
+ async () => {
108
+ throw new Error('telegram 500')
109
+ },
110
+ ),
111
+ ).rejects.toThrow('telegram 500')
112
+ }
113
+ // Window is still empty — every throw released its slot, so a full budget
114
+ // of real deliveries is allowed, and only the (MAX+1)th is capped.
115
+ for (let i = 0; i < PROGRESS_TURN_MAX; i++) {
116
+ const r = await sendWithProgressCap(
117
+ { key: KEY, now, turnStart: undefined, turnCount },
118
+ asyncSend({ message_id: i }),
119
+ )
120
+ expect(r.capped).toBe(false)
121
+ }
122
+ const over = await sendWithProgressCap(
123
+ { key: KEY, now, turnStart: undefined, turnCount },
124
+ asyncSend({ message_id: 99 }),
125
+ )
126
+ expect(over.capped).toBe(true)
127
+ })
128
+
129
+ // (c) N concurrent same-key sends never exceed the cap — fallback path.
130
+ // Pre-Low-1 (check atCap → await → record) this delivered all N, because the
131
+ // record only ran after the await, so every concurrent caller read count 0.
132
+ it('concurrent same-key sends never exceed the cap — fallback path (c)', async () => {
133
+ const turnCount = new Map<string, number>()
134
+ const now = 1_000
135
+ const delivered = { n: 0 }
136
+ const N = 25
137
+ const results = await Promise.all(
138
+ Array.from({ length: N }, () =>
139
+ sendWithProgressCap(
140
+ { key: KEY, now, turnStart: undefined, turnCount },
141
+ asyncSend({ message_id: 1 }, delivered),
142
+ ),
143
+ ),
144
+ )
145
+ const proceeded = results.filter((r) => !r.capped).length
146
+ expect(proceeded).toBe(PROGRESS_TURN_MAX)
147
+ expect(delivered.n).toBe(PROGRESS_TURN_MAX)
148
+ })
149
+
150
+ // (c) N concurrent same-key sends never exceed the cap — turn-counter path.
151
+ it('concurrent same-key sends never exceed the cap — turn path (c)', async () => {
152
+ const turnCount = new Map<string, number>([[KEY, 0]])
153
+ const delivered = { n: 0 }
154
+ const N = 25
155
+ const deps = { key: KEY, now: 1_000, turnStart: 1_000, turnCount }
156
+ const results = await Promise.all(
157
+ Array.from({ length: N }, () =>
158
+ sendWithProgressCap(deps, asyncSend({ message_id: 1 }, delivered)),
159
+ ),
160
+ )
161
+ const proceeded = results.filter((r) => !r.capped).length
162
+ expect(proceeded).toBe(PROGRESS_TURN_MAX)
163
+ expect(delivered.n).toBe(PROGRESS_TURN_MAX)
164
+ // The counter settled exactly at the cap — no overshoot, no leaked slot.
165
+ expect(turnCount.get(KEY)).toBe(PROGRESS_TURN_MAX)
166
+ })
167
+
168
+ // Non-concurrent behaviour is identical to before: a successful reserve keeps
169
+ // the slot, so reserveProgressSlot alone (no send) accounts the delivery.
170
+ it('reserveProgressSlot keeps the slot on success, releases on demand', () => {
171
+ const turnCount = new Map<string, number>([[KEY, 0]])
172
+ const deps = { key: KEY, now: 1_000, turnStart: 1_000, turnCount }
173
+ const first = reserveProgressSlot(deps)
174
+ expect(first).not.toBeNull()
175
+ expect(turnCount.get(KEY)).toBe(1)
176
+ // Releasing hands the slot back; a second release is a no-op.
177
+ first!.release()
178
+ expect(turnCount.get(KEY)).toBe(0)
179
+ first!.release()
180
+ expect(turnCount.get(KEY)).toBe(0)
181
+ })
182
+ })
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Unit tests for the `progress_update` turn-less fallback attention cap
3
+ * (`gateway/progress-fallback-cap.ts`).
4
+ *
5
+ * The bug this guards: when an inbound mints no turn atom (handback / progress
6
+ * inbound turns), the per-turn 5-call cap never applied, so a worker could
7
+ * `progress_update` at the 20s floor indefinitely. This is the real module the
8
+ * gateway calls — it exercises the rolling-window cap, the delivery-only
9
+ * counting, and the prune-to-empty memory behaviour directly.
10
+ */
11
+ import { describe, it, expect, beforeEach } from 'bun:test'
12
+ import {
13
+ progressFallbackAtCap,
14
+ recordProgressFallbackSend,
15
+ _resetProgressFallbackCap,
16
+ PROGRESS_FALLBACK_MAX,
17
+ PROGRESS_FALLBACK_WINDOW_MS,
18
+ } from '../gateway/progress-fallback-cap.js'
19
+
20
+ const KEY = 'chat123:_'
21
+
22
+ describe('progress fallback cap', () => {
23
+ beforeEach(() => {
24
+ _resetProgressFallbackCap()
25
+ })
26
+
27
+ it('allows exactly PROGRESS_FALLBACK_MAX deliveries then caps', () => {
28
+ let now = 1_000
29
+ for (let i = 0; i < PROGRESS_FALLBACK_MAX; i++) {
30
+ expect(progressFallbackAtCap(KEY, now)).toBe(false)
31
+ recordProgressFallbackSend(KEY, now)
32
+ now += 25_000 // past the 20s floor, still well inside the window
33
+ }
34
+ // The (MAX+1)th within the window is refused.
35
+ expect(progressFallbackAtCap(KEY, now)).toBe(true)
36
+ })
37
+
38
+ it('rolls: a delivery ages out after the window and frees a slot', () => {
39
+ const start = 1_000
40
+ let now = start
41
+ for (let i = 0; i < PROGRESS_FALLBACK_MAX; i++) {
42
+ recordProgressFallbackSend(KEY, now)
43
+ now += 25_000
44
+ }
45
+ expect(progressFallbackAtCap(KEY, now)).toBe(true)
46
+
47
+ // Jump past the window relative to the FIRST send: the oldest entries age
48
+ // out, so we are under cap again.
49
+ now = start + PROGRESS_FALLBACK_WINDOW_MS + 1
50
+ expect(progressFallbackAtCap(KEY, now)).toBe(false)
51
+ })
52
+
53
+ it('a slot is consumed only by recordProgressFallbackSend, not by the check', () => {
54
+ const now = 1_000
55
+ // Checking the cap repeatedly must not itself consume slots (models a
56
+ // thrown send: cap checked, send throws, nothing recorded).
57
+ for (let i = 0; i < 100; i++) {
58
+ expect(progressFallbackAtCap(KEY, now)).toBe(false)
59
+ }
60
+ // Full budget still available.
61
+ for (let i = 0; i < PROGRESS_FALLBACK_MAX; i++) {
62
+ expect(progressFallbackAtCap(KEY, now)).toBe(false)
63
+ recordProgressFallbackSend(KEY, now + i)
64
+ }
65
+ expect(progressFallbackAtCap(KEY, now + PROGRESS_FALLBACK_MAX)).toBe(true)
66
+ })
67
+
68
+ it('keys are independent', () => {
69
+ const now = 1_000
70
+ for (let i = 0; i < PROGRESS_FALLBACK_MAX; i++) {
71
+ recordProgressFallbackSend('a:_', now + i)
72
+ }
73
+ expect(progressFallbackAtCap('a:_', now + PROGRESS_FALLBACK_MAX)).toBe(true)
74
+ expect(progressFallbackAtCap('b:_', now + PROGRESS_FALLBACK_MAX)).toBe(false)
75
+ })
76
+
77
+ it('prunes an emptied key so the map does not grow unbounded', () => {
78
+ // A single delivery, then a check well past the window: the key should be
79
+ // dropped, restoring the full budget (proves the array was pruned, not
80
+ // just skipped over).
81
+ recordProgressFallbackSend(KEY, 1_000)
82
+ const later = 1_000 + PROGRESS_FALLBACK_WINDOW_MS + 1
83
+ expect(progressFallbackAtCap(KEY, later)).toBe(false)
84
+ // And a fresh full budget is available from `later`.
85
+ for (let i = 0; i < PROGRESS_FALLBACK_MAX; i++) {
86
+ expect(progressFallbackAtCap(KEY, later + i)).toBe(false)
87
+ recordProgressFallbackSend(KEY, later + i)
88
+ }
89
+ expect(progressFallbackAtCap(KEY, later + PROGRESS_FALLBACK_MAX)).toBe(true)
90
+ })
91
+ })