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
@@ -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
  })
@@ -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
+ })