switchroom 0.20.2 → 0.20.4

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 (27) hide show
  1. package/bin/handoff-briefing.sh +41 -1
  2. package/dist/cli/switchroom.js +10259 -1232
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +3 -3
  5. package/profiles/default/CLAUDE.md.hbs +13 -11
  6. package/telegram-plugin/dist/gateway/gateway.js +429 -98
  7. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  8. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  9. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  10. package/telegram-plugin/gateway/gateway.ts +128 -104
  11. package/telegram-plugin/gateway/narrative-lane.ts +4 -0
  12. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  13. package/telegram-plugin/gateway/stream-render.ts +67 -12
  14. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  15. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  16. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  17. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  18. package/telegram-plugin/registry/turns-schema.ts +26 -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/subagent-handback-inbound-builder.test.ts +46 -0
  26. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  27. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
@@ -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
+ })
@@ -8,6 +8,11 @@
8
8
  * (failed 9/1931) for the original symptom.
9
9
  */
10
10
  import { describe, it, expect, beforeEach, afterEach, spyOn, type Mock } from 'bun:test'
11
+ import {
12
+ progressFallbackAtCap,
13
+ recordProgressFallbackSend,
14
+ _resetProgressFallbackCap,
15
+ } from '../gateway/progress-fallback-cap.js'
11
16
 
12
17
  // Mock state shared across tests (simulates the module-level state in server.ts / gateway.ts)
13
18
  const progressUpdateLastSent = new Map<string, number>()
@@ -25,17 +30,24 @@ type ProgressUpdateResult =
25
30
 
26
31
  /**
27
32
  * Simplified progress_update implementation for testing.
28
- * Returns the same shape as the real tool handler.
33
+ * Mirrors the real tool handler's ORDERING (gateway.ts `executeProgressUpdate`):
34
+ * check caps → send (may throw) → count the delivery only AFTER it lands. The
35
+ * turn-less fallback path calls the REAL cap module, not a copy.
36
+ *
37
+ * `send` is an injectable seam so a test can make the send throw and assert no
38
+ * slot is consumed (Fix 2). It defaults to a successful send.
29
39
  */
30
40
  function executeProgressUpdate(args: {
31
41
  chat_id: string
32
42
  text: string
33
43
  message_thread_id?: number
44
+ send?: () => number
34
45
  }): ProgressUpdateResult {
35
46
  const { chat_id, message_thread_id } = args
36
47
  let { text } = args
37
48
  const threadId = message_thread_id
38
49
  const key = statusKey(chat_id, threadId)
50
+ const send = args.send ?? (() => Math.floor(Math.random() * 100000))
39
51
 
40
52
  // Truncate to 300 chars
41
53
  if (text.length > 300) {
@@ -53,20 +65,30 @@ function executeProgressUpdate(args: {
53
65
  }
54
66
  }
55
67
 
56
- // Turn cap: max 5 calls per turn
68
+ // Attention cap: max 5 deliveries per turn atom, else a rolling fallback
69
+ // window when no turn atom exists. Checked BEFORE the send; the count is
70
+ // advanced only after a successful send (below).
57
71
  const turnStart = activeTurnStartedAt.get(key)
58
- if (turnStart != null) {
59
- const currentCount = progressUpdateTurnCount.get(key) ?? 0
60
- if (currentCount >= 5) {
61
- return { ok: false, reason: 'turn_limit' }
62
- }
63
- progressUpdateTurnCount.set(key, currentCount + 1)
72
+ const atCap =
73
+ turnStart != null
74
+ ? (progressUpdateTurnCount.get(key) ?? 0) >= 5
75
+ : progressFallbackAtCap(key, now)
76
+ if (atCap) {
77
+ return { ok: false, reason: 'turn_limit' }
64
78
  }
65
79
 
80
+ // Send. A throw here must NOT consume a slot — it propagates before any
81
+ // bookkeeping runs.
82
+ const message_id = send()
83
+
66
84
  progressUpdateLastSent.set(key, now)
85
+ if (turnStart != null) {
86
+ progressUpdateTurnCount.set(key, (progressUpdateTurnCount.get(key) ?? 0) + 1)
87
+ } else {
88
+ recordProgressFallbackSend(key, now)
89
+ }
67
90
 
68
- // Mock message_id
69
- return { ok: true, message_id: Math.floor(Math.random() * 100000) }
91
+ return { ok: true, message_id }
70
92
  }
71
93
 
72
94
  // Manual time mocking — bun:test compatible (bun lacks vi.setSystemTime).
@@ -81,6 +103,7 @@ describe('progress_update tool', () => {
81
103
  progressUpdateLastSent.clear()
82
104
  progressUpdateTurnCount.clear()
83
105
  activeTurnStartedAt.clear()
106
+ _resetProgressFallbackCap()
84
107
  mockNow = 1000
85
108
  dateSpy = spyOn(Date, 'now').mockImplementation(() => mockNow)
86
109
  })
@@ -220,7 +243,7 @@ describe('progress_update tool', () => {
220
243
  expect(progressUpdateTurnCount.get(key2)).toBe(1)
221
244
  })
222
245
 
223
- it('when no active turn, still rate-limits but does not increment counter', () => {
246
+ it('when no active turn, still rate-limits but does not increment the turn counter', () => {
224
247
  // No activeTurnStartedAt entry for this chat
225
248
  const r1 = executeProgressUpdate({ chat_id: '999', text: 'First' })
226
249
  expect(r1.ok).toBe(true)
@@ -229,8 +252,81 @@ describe('progress_update tool', () => {
229
252
  const r2 = executeProgressUpdate({ chat_id: '999', text: 'Second' })
230
253
  expect(r2.ok).toBe(false)
231
254
 
232
- // Counter should not have been incremented (no active turn)
255
+ // The per-turn counter is untouched on the turn-less path (the fallback
256
+ // window carries the count instead).
233
257
  const key = statusKey('999')
234
258
  expect(progressUpdateTurnCount.get(key)).toBeUndefined()
235
259
  })
260
+
261
+ // Fix 1: with NO turn atom, the fallback rolling window still caps at 5.
262
+ it('null-turn-atom context caps at 5 sends per window (Fix 1)', () => {
263
+ // No activeTurnStartedAt entry — the pre-fix code applied no cap here at
264
+ // all, so this loop would let a 6th (and every subsequent) send through.
265
+ for (let i = 1; i <= 5; i++) {
266
+ advance(25_000) // clear the 20s floor each time
267
+ const r = executeProgressUpdate({ chat_id: '888', text: `Update ${i}` })
268
+ expect(r.ok).toBe(true)
269
+ }
270
+ advance(25_000)
271
+ const r6 = executeProgressUpdate({ chat_id: '888', text: 'Update 6' })
272
+ expect(r6.ok).toBe(false)
273
+ if (!r6.ok) {
274
+ expect(r6.reason).toBe('turn_limit')
275
+ }
276
+ })
277
+
278
+ // Fix 2: a thrown send must NOT consume a cap slot (turn-scoped path).
279
+ it('a thrown send does not consume a turn-cap slot (Fix 2)', () => {
280
+ const key = statusKey('777')
281
+ activeTurnStartedAt.set(key, 1000)
282
+ progressUpdateTurnCount.set(key, 0)
283
+
284
+ // A send that throws: the cap was checked, but nothing was delivered.
285
+ expect(() =>
286
+ executeProgressUpdate({
287
+ chat_id: '777',
288
+ text: 'boom',
289
+ send: () => {
290
+ throw new Error('telegram 500')
291
+ },
292
+ }),
293
+ ).toThrow('telegram 500')
294
+
295
+ // Slot NOT consumed — pre-fix the counter incremented before the send.
296
+ expect(progressUpdateTurnCount.get(key)).toBe(0)
297
+
298
+ // A subsequent successful send still has its full budget and counts once.
299
+ advance(25_000)
300
+ const r = executeProgressUpdate({ chat_id: '777', text: 'real' })
301
+ expect(r.ok).toBe(true)
302
+ expect(progressUpdateTurnCount.get(key)).toBe(1)
303
+ })
304
+
305
+ // Fix 2 composed with Fix 1: a thrown send on the turn-less path records
306
+ // nothing in the fallback window either.
307
+ it('a thrown send does not consume a fallback-window slot (Fix 2 × Fix 1)', () => {
308
+ // Five throwing sends on the turn-less path.
309
+ for (let i = 0; i < 5; i++) {
310
+ advance(25_000)
311
+ expect(() =>
312
+ executeProgressUpdate({
313
+ chat_id: '666',
314
+ text: 'boom',
315
+ send: () => {
316
+ throw new Error('telegram 500')
317
+ },
318
+ }),
319
+ ).toThrow('telegram 500')
320
+ }
321
+ // The window is still empty — five throws consumed no slots, so five real
322
+ // sends are still allowed.
323
+ for (let i = 1; i <= 5; i++) {
324
+ advance(25_000)
325
+ const r = executeProgressUpdate({ chat_id: '666', text: `real ${i}` })
326
+ expect(r.ok).toBe(true)
327
+ }
328
+ advance(25_000)
329
+ const capped = executeProgressUpdate({ chat_id: '666', text: 'over' })
330
+ expect(capped.ok).toBe(false)
331
+ })
236
332
  })
@@ -165,3 +165,49 @@ describe('buildSubagentHandbackInbound', () => {
165
165
  expect(inbound.meta.message_thread_id).toBeUndefined()
166
166
  })
167
167
  })
168
+
169
+ // ─── msg-6897 misroute regression (2026-08-04): meta.chat_id is LOAD-BEARING ──
170
+ // The gateway's enqueue handler (`beginTurn`, stream-render.ts) gates the
171
+ // ENTIRE turn-atom mint — `recordTurnStart` (the `turns` row) AND
172
+ // `writeTurnActiveMarker` (the #2085 dispatch-time stamp source) — on
173
+ // `ev.chatId`, which is parsed from the channel XML's `chat_id` attribute,
174
+ // rendered ONLY from meta (bridge.ts onInbound → mcp.notification meta).
175
+ // Without meta.chat_id a handback turn registers NO surface, so a worker
176
+ // dispatched from inside it has a NULL parent_turn_key (no marker to stamp
177
+ // from, no turn window for the backfill) and its card/handback misroutes to
178
+ // the owner DM with the thread stripped. Mirrors the real-inbound shape
179
+ // (inbound-router.ts buildInboundEnvelope meta.chat_id) and the
180
+ // resume_interrupted builder's identical fix.
181
+ describe('buildSubagentHandbackInbound — meta.chat_id turn registration (msg-6897)', () => {
182
+ it('carries the origin chat as meta.chat_id so the handback turn mints a turn atom', () => {
183
+ const inbound = buildSubagentHandbackInbound({
184
+ ctx: {
185
+ chatId: '-1004223464247',
186
+ threadId: 77,
187
+ taskDescription: 'Topic-dispatched work',
188
+ resultText: 'done',
189
+ outcome: 'completed',
190
+ },
191
+ nowMs: FIXED_NOW,
192
+ })
193
+ expect(inbound.meta.chat_id).toBe('-1004223464247')
194
+ // The thread carrier must still ride alongside — chat_id + thread_id is
195
+ // the full origin surface the minted turn (and any worker dispatched
196
+ // inside it) inherits.
197
+ expect(inbound.meta.message_thread_id).toBe('77')
198
+ })
199
+
200
+ it('carries meta.chat_id for DM-shaped chats too (no thread)', () => {
201
+ const inbound = buildSubagentHandbackInbound({
202
+ ctx: {
203
+ chatId: '12345',
204
+ taskDescription: 'x',
205
+ resultText: 'y',
206
+ outcome: 'failed',
207
+ },
208
+ nowMs: FIXED_NOW,
209
+ })
210
+ expect(inbound.meta.chat_id).toBe('12345')
211
+ expect(inbound.meta.message_thread_id).toBeUndefined()
212
+ })
213
+ })