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.
- package/dist/cli/switchroom.js +10256 -1233
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +452 -99
- 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 +130 -104
- package/telegram-plugin/gateway/narrative-lane.ts +19 -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/silence-poke.ts +80 -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/silence-poke-card-render.test.ts +300 -0
- 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
|
@@ -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
|
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4330 — a visibly-updating pinned activity card must not be treated as
|
|
3
|
+
* silence by the 300s terminal fallback.
|
|
4
|
+
*
|
|
5
|
+
* The bug (user-reported, with screenshot): the framework fired the terminal
|
|
6
|
+
* unwedge — "⚠️ no output for 5 min — the framework ended that stalled turn"
|
|
7
|
+
* — on a HEALTHY turn whose pinned "→ Working… · Nm · N tools" status card
|
|
8
|
+
* WAS actively updating the whole time. The card edits are driven by
|
|
9
|
+
* `feedHeartbeatTick` (the 0-label climb / labelled-step elapsed re-render)
|
|
10
|
+
* through `drainActivitySummary`'s editMessageText, and that transport path
|
|
11
|
+
* had NO `noteProduction`/`noteOutbound` site — deliberately, because the
|
|
12
|
+
* heartbeat climbs on pure wall clock and an unbounded clock RESET would keep
|
|
13
|
+
* a genuinely hung turn alive forever (the #1556 class). So when every defer
|
|
14
|
+
* signal (in-flight tool, pending async dispatch, alive shells, compaction)
|
|
15
|
+
* read false at the 300s tick, the fallback tore down the very card the user
|
|
16
|
+
* was watching and re-asked their message.
|
|
17
|
+
*
|
|
18
|
+
* The fix: `drainActivitySummary` stamps `silencePoke.noteCardRender` on
|
|
19
|
+
* every card render that actually lands (open or non-shed edit); tick()
|
|
20
|
+
* DEFERS the terminal fallback while the last landed render is younger than
|
|
21
|
+
* `CARD_RENDER_FRESH_MS`, bounded by `fallbackHardCeiling` exactly like the
|
|
22
|
+
* #1292/#3519/#4058 defers. These tests drive the REAL tick loop (and, for
|
|
23
|
+
* the wiring case, the REAL narrative-lane transport) and assert outcomes:
|
|
24
|
+
* - card renders arriving faster than the fresh window → NO fire past 300s;
|
|
25
|
+
* - renders that keep coming forever → STILL fires at the hard ceiling
|
|
26
|
+
* (a hung turn's heartbeat keeps climbing, so the net must stay bounded);
|
|
27
|
+
* - a genuinely silent turn (no renders, no tool, no reply) → STILL fires
|
|
28
|
+
* at 300s (the safety net is not disabled);
|
|
29
|
+
* - renders that STOP → the fallback fires once silence ≥ threshold and
|
|
30
|
+
* the last render has aged out (no unearned extension);
|
|
31
|
+
* - wiring: a card edit through the REAL `createNarrativeLane` drain
|
|
32
|
+
* defers the fallback for its status key.
|
|
33
|
+
*/
|
|
34
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
35
|
+
import { readFileSync } from 'node:fs'
|
|
36
|
+
import { resolve } from 'node:path'
|
|
37
|
+
import { tmpdir } from 'node:os'
|
|
38
|
+
import { createNarrativeLane } from '../gateway/narrative-lane.js'
|
|
39
|
+
import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
|
|
40
|
+
import * as silencePoke from '../silence-poke.js'
|
|
41
|
+
import {
|
|
42
|
+
startTurn,
|
|
43
|
+
noteCardRender,
|
|
44
|
+
__tickForTests,
|
|
45
|
+
__setDepsForTests,
|
|
46
|
+
__getStateForTests,
|
|
47
|
+
__resetAllForTests,
|
|
48
|
+
DEFAULT_THRESHOLDS,
|
|
49
|
+
CARD_RENDER_FRESH_MS,
|
|
50
|
+
type SilencePokeMetric,
|
|
51
|
+
type FrameworkFallbackContext,
|
|
52
|
+
type ThresholdsMs,
|
|
53
|
+
} from '../silence-poke.js'
|
|
54
|
+
|
|
55
|
+
const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
|
|
56
|
+
|
|
57
|
+
interface TestFixtures {
|
|
58
|
+
emitted: SilencePokeMetric[]
|
|
59
|
+
fallbacks: FrameworkFallbackContext[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setupDeps(opts?: { thresholdsMs?: ThresholdsMs }): TestFixtures {
|
|
63
|
+
const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
|
|
64
|
+
__setDepsForTests({
|
|
65
|
+
emitMetric: (e) => fixtures.emitted.push(e),
|
|
66
|
+
onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
|
|
67
|
+
thresholdsMs: opts?.thresholdsMs
|
|
68
|
+
?? { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
|
|
69
|
+
// Mirror production wiring: the callback path is active (liveness-wiring
|
|
70
|
+
// always wires isLegitimatelyWorking), returning false = "no tool work".
|
|
71
|
+
isLegitimatelyWorking: () => false,
|
|
72
|
+
})
|
|
73
|
+
return fixtures
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
__resetAllForTests()
|
|
78
|
+
delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
79
|
+
delete process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
__resetAllForTests()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('silence-poke — #4330 card-render defer (outcome)', () => {
|
|
87
|
+
it('a turn whose ONLY output is card renders faster than the fresh window is NOT torn down past 300s', () => {
|
|
88
|
+
const fx = setupDeps()
|
|
89
|
+
startTurn('chat:1', 0)
|
|
90
|
+
// The heartbeat edits the card every ~6s; simulate a card that keeps
|
|
91
|
+
// visibly updating for 10 minutes with no reply, no tool, no draft.
|
|
92
|
+
for (let t = 6_000; t <= 600_000; t += 6_000) {
|
|
93
|
+
noteCardRender('chat:1', t)
|
|
94
|
+
__tickForTests(t)
|
|
95
|
+
}
|
|
96
|
+
// Pre-fix this fired at the first tick with silence >= 300_000 — the
|
|
97
|
+
// exact "framework ended that stalled turn" false positive.
|
|
98
|
+
expect(fx.fallbacks).toHaveLength(0)
|
|
99
|
+
expect(fx.emitted).toHaveLength(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('card renders that keep coming forever STILL fire the fallback at the hard ceiling (bounded defer)', () => {
|
|
103
|
+
const fx = setupDeps()
|
|
104
|
+
startTurn('chat:2', 0)
|
|
105
|
+
// A hung turn: the framework heartbeat keeps climbing the card on pure
|
|
106
|
+
// wall clock even though the model is dead. The defer must stay bounded.
|
|
107
|
+
let fired: number | null = null
|
|
108
|
+
for (let t = 6_000; t <= 1_200_000; t += 6_000) {
|
|
109
|
+
noteCardRender('chat:2', t)
|
|
110
|
+
__tickForTests(t)
|
|
111
|
+
if (fx.fallbacks.length > 0 && fired == null) fired = t
|
|
112
|
+
}
|
|
113
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
114
|
+
expect(fired).not.toBeNull()
|
|
115
|
+
expect(fired!).toBeGreaterThanOrEqual(HARD_CEILING)
|
|
116
|
+
// Fires promptly AT the ceiling, not some window after it.
|
|
117
|
+
expect(fired!).toBeLessThanOrEqual(HARD_CEILING + 6_000)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('a genuinely silent turn (no card renders, no tool, no reply) STILL fires at 300s — the net is intact', () => {
|
|
121
|
+
const fx = setupDeps()
|
|
122
|
+
startTurn('chat:3', 0)
|
|
123
|
+
__tickForTests(299_000)
|
|
124
|
+
expect(fx.fallbacks).toHaveLength(0)
|
|
125
|
+
__tickForTests(300_000)
|
|
126
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
127
|
+
expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('card renders that STOP do not extend the window — fallback fires once the last render ages out', () => {
|
|
131
|
+
const fx = setupDeps()
|
|
132
|
+
startTurn('chat:4', 0)
|
|
133
|
+
// Card updated until t=100s (turn wedged after; heartbeat drain stuck /
|
|
134
|
+
// card torn down), then nothing.
|
|
135
|
+
for (let t = 6_000; t <= 100_000; t += 6_000) {
|
|
136
|
+
noteCardRender('chat:4', t)
|
|
137
|
+
__tickForTests(t)
|
|
138
|
+
}
|
|
139
|
+
// At 305s: silence (never reset by noteCardRender) is 305s >= 300s and
|
|
140
|
+
// the last render is 200s+ old — well past CARD_RENDER_FRESH_MS. Fires.
|
|
141
|
+
__tickForTests(305_000)
|
|
142
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
143
|
+
expect(fx.fallbacks[0]!.silenceMs).toBe(305_000)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('noteCardRender never resets the silence clock or re-arms a fired fallback', () => {
|
|
147
|
+
setupDeps()
|
|
148
|
+
startTurn('chat:5', 0)
|
|
149
|
+
noteCardRender('chat:5', 250_000)
|
|
150
|
+
const s = __getStateForTests('chat:5')!
|
|
151
|
+
expect(s.lastOutboundAt).toBeNull() // clock untouched — defer only
|
|
152
|
+
expect(s.lastCardRenderAt).toBe(250_000)
|
|
153
|
+
expect(s.fallbackFired).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ── wiring proof: the REAL narrative-lane card drain stamps the defer ─────
|
|
158
|
+
|
|
159
|
+
const CHAT = '1001'
|
|
160
|
+
|
|
161
|
+
function makeLane() {
|
|
162
|
+
const noop = () => {}
|
|
163
|
+
let nextId = 3000
|
|
164
|
+
const calls: Array<{ method: string }> = []
|
|
165
|
+
const api = {
|
|
166
|
+
sendRichMessage: async () => {
|
|
167
|
+
calls.push({ method: 'sendRichMessage' })
|
|
168
|
+
return { message_id: ++nextId }
|
|
169
|
+
},
|
|
170
|
+
editMessageText: async () => {
|
|
171
|
+
calls.push({ method: 'editMessageText' })
|
|
172
|
+
return {}
|
|
173
|
+
},
|
|
174
|
+
deleteMessage: async () => true,
|
|
175
|
+
}
|
|
176
|
+
const fakeEA = {
|
|
177
|
+
mayDrain: () => true,
|
|
178
|
+
openOrEditCard: (_p: string, fn: () => void) => fn(),
|
|
179
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
180
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
181
|
+
}
|
|
182
|
+
const deps = {
|
|
183
|
+
ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/silence-card-render-activity-cards.json`,
|
|
184
|
+
CLEAR_STATUS_ON_COMPLETION: false,
|
|
185
|
+
FEED_HEARTBEAT_ENABLED: false,
|
|
186
|
+
FEED_HEARTBEAT_MIN_STALE_MS: 6000,
|
|
187
|
+
FEED_LIVENESS_OPEN_ENABLED: false,
|
|
188
|
+
FEED_LIVENESS_OPEN_MS: 5000,
|
|
189
|
+
PIN_STATUS_WHILE_WORKING: false,
|
|
190
|
+
POST_ANSWER_LIVENESS_STALE_MS: 90000,
|
|
191
|
+
STATIC: false,
|
|
192
|
+
activeDraftStreams: new Map(),
|
|
193
|
+
activityCardPersistEnabled: false,
|
|
194
|
+
activityCardStoreFs: { readFileSync: () => '', writeFileSync: noop, mkdirSync: noop, renameSync: noop, unlinkSync: noop },
|
|
195
|
+
bot: { api },
|
|
196
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
197
|
+
currentTurnMap: { get: () => null, byKey: new Map() },
|
|
198
|
+
earlyLivenessOpenTimers: new Map(),
|
|
199
|
+
emissionAuthorityFor: () => fakeEA,
|
|
200
|
+
feedOpenGateDeps: () => ({ hasOutboundDeliveredSince: () => false, historyEnabled: false, finalAnswerMinChars: 200 }),
|
|
201
|
+
getCurrentTurn: () => null,
|
|
202
|
+
reconcileStatusPin: noop,
|
|
203
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
204
|
+
// Same key shape silence-poke is driven with below.
|
|
205
|
+
statusKey: (c: string, t?: number | null) => `${c}:${t ?? ''}`,
|
|
206
|
+
} as unknown as NarrativeLaneDeps
|
|
207
|
+
const lane = createNarrativeLane(deps)
|
|
208
|
+
return { lane, calls }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>): CurrentTurn {
|
|
212
|
+
const turn = {
|
|
213
|
+
turnId: 'turn-card-render-1',
|
|
214
|
+
sessionChatId: CHAT,
|
|
215
|
+
sessionThreadId: undefined,
|
|
216
|
+
sourceMessageId: null,
|
|
217
|
+
registryKey: null,
|
|
218
|
+
startedAt: Date.now() - 3000,
|
|
219
|
+
currentModel: null,
|
|
220
|
+
totalTokens: 0,
|
|
221
|
+
labeledToolCount: 0,
|
|
222
|
+
mirrorLines: [] as string[],
|
|
223
|
+
foregroundSubAgents: new Map<string, string[]>(),
|
|
224
|
+
activityPendingRender: null as string | null,
|
|
225
|
+
activityLastSentRender: null as string | null,
|
|
226
|
+
activityMessageId: null as number | null,
|
|
227
|
+
activityInFlight: null as Promise<void> | null,
|
|
228
|
+
activityEverOpened: false,
|
|
229
|
+
activityDrainFailures: 0,
|
|
230
|
+
finalAnswerEverDelivered: false,
|
|
231
|
+
finalAnswerDelivered: false,
|
|
232
|
+
replyCalled: false,
|
|
233
|
+
capturedText: [] as string[],
|
|
234
|
+
lastReplyText: '',
|
|
235
|
+
answerStream: null,
|
|
236
|
+
liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
|
|
237
|
+
} as unknown as CurrentTurn
|
|
238
|
+
;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
|
|
239
|
+
return turn
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
describe('silence-poke — #4330 wiring: the REAL card drain defers the fallback', () => {
|
|
243
|
+
it('a card render landed through drainActivitySummary defers a due fallback; without it the fallback fires', async () => {
|
|
244
|
+
const key = `${CHAT}:`
|
|
245
|
+
const now = Date.now()
|
|
246
|
+
// Real wall clock end-to-end: tiny fallback threshold, real
|
|
247
|
+
// CARD_RENDER_FRESH_MS (the lane stamps Date.now()).
|
|
248
|
+
const fx = setupDeps({
|
|
249
|
+
thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Control first: a turn past the threshold with NO card render fires.
|
|
253
|
+
startTurn(key, now - 5_000)
|
|
254
|
+
__tickForTests(now)
|
|
255
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
256
|
+
|
|
257
|
+
// Now the same shape, but a card render lands through the REAL lane
|
|
258
|
+
// transport before the tick — the fallback must be deferred.
|
|
259
|
+
__resetAllForTests()
|
|
260
|
+
const fx2 = setupDeps({
|
|
261
|
+
thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
|
|
262
|
+
})
|
|
263
|
+
startTurn(key, Date.now() - 5_000)
|
|
264
|
+
const { lane, calls } = makeLane()
|
|
265
|
+
const turn = makeLaneTurn(lane)
|
|
266
|
+
lane.showNarrativeStep(turn, 'Compiling the release notes')
|
|
267
|
+
await turn.activityInFlight
|
|
268
|
+
expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
269
|
+
const st = __getStateForTests(key)!
|
|
270
|
+
expect(st.lastCardRenderAt).not.toBeNull() // the drain stamped it
|
|
271
|
+
__tickForTests(Date.now())
|
|
272
|
+
expect(fx2.fallbacks).toHaveLength(0) // deferred: the card just moved
|
|
273
|
+
|
|
274
|
+
// And the defer stays bounded: past the hard ceiling it fires anyway,
|
|
275
|
+
// even with a render landed inside the fresh window at fire time.
|
|
276
|
+
const tFinal = Date.now() + HARD_CEILING + CARD_RENDER_FRESH_MS
|
|
277
|
+
noteCardRender(key, tFinal - 1_000) // card still "moving" at the ceiling
|
|
278
|
+
__tickForTests(tFinal)
|
|
279
|
+
expect(fx2.fallbacks).toHaveLength(1)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// ── structural: progress_update (a model-driven user-visible send inside the
|
|
284
|
+
// gateway IIFE, not instantiable in-process — same pattern as
|
|
285
|
+
// silence-liveness-wiring.test.ts) must reset the silence clock ──────────
|
|
286
|
+
|
|
287
|
+
describe('silence-poke — #4330 progress_update liveness wiring (structural)', () => {
|
|
288
|
+
it('the progress_update handler calls silencePoke.noteOutbound after the send lands', () => {
|
|
289
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
290
|
+
const start = "if (!args.chat_id) throw new Error('progress_update: chat_id is required')"
|
|
291
|
+
const end = '`ask_user` MCP tool'
|
|
292
|
+
const block = (gatewaySrc.split(start)[1] ?? '').split(end)[0] ?? ''
|
|
293
|
+
expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler
|
|
294
|
+
// A progress_update is a fresh user-visible outbound the model authored —
|
|
295
|
+
// it must reset the 300s silence clock exactly like a reply send does
|
|
296
|
+
// (outbound-send-path.ts). Pre-#4330 only signalTracker was ticked, so a
|
|
297
|
+
// turn narrating solely via progress_update was torn down as "silent".
|
|
298
|
+
expect(block).toMatch(/silencePoke\.noteOutbound\(key, Date\.now\(\)\)/)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -353,3 +353,29 @@ describe('decideSubagentProgress', () => {
|
|
|
353
353
|
}
|
|
354
354
|
})
|
|
355
355
|
})
|
|
356
|
+
|
|
357
|
+
// ─── msg-6897 misroute regression (2026-08-04): meta.chat_id is LOAD-BEARING ──
|
|
358
|
+
// Same turn-registration contract as the handback builder (see
|
|
359
|
+
// subagent-handback-inbound-builder.test.ts): without meta.chat_id the
|
|
360
|
+
// progress turn's channel XML has no chat_id, the gateway mints no turn atom
|
|
361
|
+
// (no `turns` row, no `turn-active.json`), and a worker dispatched from
|
|
362
|
+
// inside the progress turn can never be attributed to its chat/topic.
|
|
363
|
+
describe('buildSubagentProgressInbound — meta.chat_id turn registration (msg-6897)', () => {
|
|
364
|
+
it('carries the origin chat as meta.chat_id', () => {
|
|
365
|
+
const inbound = buildSubagentProgressInbound({
|
|
366
|
+
ctx: {
|
|
367
|
+
chatId: '-1004223464247',
|
|
368
|
+
threadId: 77,
|
|
369
|
+
subagentJsonlId: 'stem1',
|
|
370
|
+
taskDescription: 'Topic work',
|
|
371
|
+
latestSummary: 'still going',
|
|
372
|
+
elapsedMs: 6 * 60_000,
|
|
373
|
+
bucketIdx: 1,
|
|
374
|
+
progressIntervalMs: 5 * 60_000,
|
|
375
|
+
},
|
|
376
|
+
nowMs: 1_700_000_000_000,
|
|
377
|
+
})
|
|
378
|
+
expect(inbound.meta.chat_id).toBe('-1004223464247')
|
|
379
|
+
expect(inbound.meta.message_thread_id).toBe('77')
|
|
380
|
+
})
|
|
381
|
+
})
|