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.
- package/bin/handoff-briefing.sh +41 -1
- package/dist/cli/switchroom.js +10259 -1232
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/profiles/default/CLAUDE.md.hbs +13 -11
- package/telegram-plugin/dist/gateway/gateway.js +429 -98
- package/telegram-plugin/edit-flood-fuse.ts +332 -14
- package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
- package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
- package/telegram-plugin/gateway/gateway.ts +128 -104
- package/telegram-plugin/gateway/narrative-lane.ts +4 -0
- package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
- package/telegram-plugin/gateway/stream-render.ts +67 -12
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
- package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
- package/telegram-plugin/registry/subagents-schema.ts +61 -0
- package/telegram-plugin/registry/turns-schema.ts +26 -0
- package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
- package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
- package/telegram-plugin/tests/progress-cap.test.ts +182 -0
- package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
- package/telegram-plugin/tests/progress-update.test.ts +108 -12
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
- 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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
})
|