switchroom 0.19.4 → 0.19.6

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 (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -736,10 +736,15 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
736
736
  expect(decision.reason).toBe('no-state')
737
737
  })
738
738
 
739
- it('a genuinely empty turn (no substantive prose) → hook blocks WITHOUT pendingText decide falls through', () => {
740
- // Short closer under the substance floor: not a dropped answer. The hook
741
- // still blocks (re-prompt), but writes NO pendingText, so the gateway
742
- // takes the existing recordUndeliveredTurnEnd / represent path unchanged.
739
+ it('a short single-block answer → hook persists pendingText but default-floor decide falls through', () => {
740
+ // Single short trailing block under the 200-char substance floor. Post
741
+ // duplicate-message fix the hook persists it as pendingText (so the
742
+ // capture-divergence corner can deliver it with a lowered floor when the
743
+ // gateway captured nothing), but under the DEFAULT floor the gateway's
744
+ // decide returns no-substantive-prose — the normal recordUndeliveredTurnEnd
745
+ // / represent path is unchanged. (No gateway heartbeat file exists in this
746
+ // test's state dir, so the single-writer election also BLOCKS on the
747
+ // liveness gate rather than allowing the stop.)
743
748
  const transcript = writeTranscript([
744
749
  ENQUEUE,
745
750
  { type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
@@ -747,10 +752,60 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
747
752
  const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
748
753
  expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
749
754
  const state = readSilentEndState()!
750
- expect(state.pendingText).toBeUndefined()
755
+ expect(state.pendingText).toBe('ok done')
756
+ // Default floor (200) → short prose is not delivered on the normal path.
751
757
  const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
752
758
  expect(decision.deliver).toBe(false)
753
759
  expect(decision.reason).toBe('no-substantive-prose')
760
+ // Lowered floor (capture-divergence corner) → the short answer IS delivered.
761
+ const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
762
+ expect(lowered.deliver).toBe(true)
763
+ expect(lowered.text).toBe('ok done')
764
+ })
765
+
766
+ it('review item 3: multi sub-200 REAL blocks + capture-empty corner → bridge delivers JOINED prose (no drop)', () => {
767
+ // A real answer split as two ~150-char paragraphs, each under the 200-char
768
+ // floor. Pre-fix: scan set NO pendingText → in the capture-divergence-empty
769
+ // corner (gateway captured nothing → flush skips 'empty-text') the bridge
770
+ // had nothing → hook already allowed the stop → DROPPED ANSWER. Post-fix
771
+ // the hook persists the JOINED prose so the lowered-floor bridge delivers
772
+ // exactly one message.
773
+ // Each block short enough that the JOINED prose is still under the 200-char
774
+ // default floor — so this also exercises the lowered-floor plumbing.
775
+ const PARA_1 = 'The root cause is a stale cache entry surviving reload.'
776
+ const PARA_2 = 'The fix keys invalidation off the canonical id; verified.'
777
+ const joined = `${PARA_1}\n\n${PARA_2}`
778
+ expect(joined.length).toBeLessThan(200)
779
+ const transcript = writeTranscript([
780
+ ENQUEUE,
781
+ { type: 'assistant', message: { content: [{ type: 'text', text: PARA_1 }] } },
782
+ { type: 'assistant', message: { content: [{ type: 'text', text: PARA_2 }] } },
783
+ ])
784
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
785
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
786
+ const state = readSilentEndState()!
787
+ expect(state.pendingText).toBe(joined)
788
+ // Default floor → not delivered on the normal path (no duplicate risk).
789
+ expect(decideCapturedProseDelivery({ turnKey: 'c:_' }).deliver).toBe(false)
790
+ // Capture-divergence corner (gateway captured empty → lowered floor) →
791
+ // exactly one delivery of the joined answer, instead of a silent drop.
792
+ const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
793
+ expect(lowered.deliver).toBe(true)
794
+ expect(lowered.text).toBe(joined)
795
+ })
796
+
797
+ it('review item 3: NARRATION-only multi-block → still NO pendingText (no masquerade, #3228 Finding 2)', () => {
798
+ const transcript = writeTranscript([
799
+ ENQUEUE,
800
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check the first source now, scanning the rows…' }] } },
801
+ { type: 'assistant', message: { content: [{ type: 'text', text: "Now let me query the second source; hang tight…" }] } },
802
+ ])
803
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
804
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
805
+ const state = readSilentEndState()!
806
+ expect(state.pendingText).toBeUndefined()
807
+ // Even at the lowered floor there is nothing to deliver.
808
+ expect(decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 }).deliver).toBe(false)
754
809
  })
755
810
 
756
811
  it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {
@@ -238,7 +238,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
238
238
  assertSendable: () => {},
239
239
  statusKey: key,
240
240
  streamKey: key,
241
- resolveReplyOwnerTurn: () => null,
241
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
242
+ getLastSubagentHandbackAt: () => null,
242
243
  findTurnByOriginId: () => null,
243
244
  findTurnByQuotedMessageId: () => null,
244
245
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Unit coverage for the per-chat subagent-handback marker
3
+ * (fix/backstop-duplicate-reply). The marker is the deterministic signal the
4
+ * supersede path uses to tell a flushed turn's OWN reworded late reply (no
5
+ * handback in flight → supersede) from a background handback attributed to that
6
+ * ended turn (handback in flight → keep the #3429 content gate).
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
11
+
12
+ describe('SubagentHandbackMarker', () => {
13
+ it('returns null for a chat with no recorded handback', () => {
14
+ const m = new SubagentHandbackMarker()
15
+ expect(m.lastAt('chatA')).toBe(null)
16
+ })
17
+
18
+ it('returns the recorded enqueue ts for the chat', () => {
19
+ const m = new SubagentHandbackMarker()
20
+ m.record('chatA', 1_000_000)
21
+ expect(m.lastAt('chatA')).toBe(1_000_000)
22
+ })
23
+
24
+ it('is per-chat — one chat never leaks into another', () => {
25
+ const m = new SubagentHandbackMarker()
26
+ m.record('chatA', 1_000_000)
27
+ expect(m.lastAt('chatB')).toBe(null)
28
+ })
29
+
30
+ it('keeps only the MOST RECENT enqueue (overwrites)', () => {
31
+ const m = new SubagentHandbackMarker()
32
+ m.record('chatA', 1_000_000)
33
+ m.record('chatA', 1_050_000)
34
+ expect(m.lastAt('chatA')).toBe(1_050_000)
35
+ })
36
+ })
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Worker-feed origin-race defer (DM-misrouted worker card).
3
+ *
4
+ * Root cause: the gateway picks a worker card's destination on the FIRST
5
+ * progress tick of a new sub-agent. That tick can beat the async
6
+ * `jsonl_agent_id` backfill that links the sub-agent's registry row to its
7
+ * origin turn (retried ~every 3s). Until linked, origin resolution returns
8
+ * null and the destination hard-falls back to the owner DM — CREATING the card
9
+ * there. The origin (supergroup + forum topic) resolves ~3s later, but the DM
10
+ * card already exists and stays the visible one.
11
+ *
12
+ * Fix (approach #1 — defer): when the agent is not yet linked AND no card
13
+ * exists yet, SKIP card creation for that tick. The watcher re-fires within
14
+ * seconds; once the backfill links the row the card is created in the correct
15
+ * chat+topic.
16
+ *
17
+ * These tests exercise the REAL exported decision function
18
+ * `decideWorkerFeedDestination` (worker-feed-dispatch.ts) — the SAME code the
19
+ * gateway's `onProgress` block calls (issue #3460) — plus, in the integration
20
+ * case, the REAL `createWorkerActivityFeed` with a mock Bot API that records
21
+ * every send. A regression in the gateway's defer/route decision now fails a
22
+ * test, because the test binds to the shared function the gateway runs, not to
23
+ * a hand-rolled replica.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import { decideWorkerFeedDestination } from '../gateway/worker-feed-dispatch.js'
27
+ import {
28
+ createWorkerActivityFeed,
29
+ type BotApiForWorkerFeed,
30
+ type WorkerActivityView,
31
+ } from '../worker-activity-feed.js'
32
+
33
+ const MAX = 10
34
+ const OWNER_DM = '111111'
35
+ const ORIGIN_CHAT = '-1002000000000' // supergroup
36
+ const ORIGIN_THREAD = 42 // forum topic
37
+
38
+ // The gateway resolves the owner DM from access config; the extracted pure
39
+ // function takes it as an explicit input. All calls in this suite pass the
40
+ // same owner DM so a fall-through to it is observable.
41
+ function decide(overrides: Partial<Parameters<typeof decideWorkerFeedDestination>[0]>) {
42
+ return decideWorkerFeedDestination({
43
+ origin: null,
44
+ cardExists: false,
45
+ priorDeferrals: 0,
46
+ maxDeferrals: MAX,
47
+ fleetChatId: '',
48
+ stampChatId: undefined,
49
+ stampThreadId: undefined,
50
+ ownerDm: OWNER_DM,
51
+ ...overrides,
52
+ })
53
+ }
54
+
55
+ describe('decideWorkerFeedDestination (origin-race defer + routing decision)', () => {
56
+ it('(a) unlinked + no card → DEFER (no paint)', () => {
57
+ const out = decide({ origin: null, cardExists: false, priorDeferrals: 0 })
58
+ expect(out.action).toBe('defer')
59
+ if (out.action === 'defer') expect(out.deferrals).toBe(1)
60
+ })
61
+
62
+ it('(b) linked → PAINT to the origin chat + forum thread', () => {
63
+ const out = decide({
64
+ origin: { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD },
65
+ cardExists: false,
66
+ priorDeferrals: 3,
67
+ })
68
+ expect(out.action).toBe('paint')
69
+ if (out.action === 'paint') {
70
+ expect(out.chatId).toBe(ORIGIN_CHAT)
71
+ expect(out.threadId).toBe(ORIGIN_THREAD)
72
+ expect(out.deferrals).toBe(0)
73
+ expect(out.exhausted).toBe(false)
74
+ expect(out.ownerDmFallback).toBe(false)
75
+ }
76
+ })
77
+
78
+ it('(c) exhausted defer (never-backfill, forum) → PAINT to the stamp chat + stamp FORUM THREAD, not owner DM', () => {
79
+ // priorDeferrals = MAX-1 → this tick is the MAX-th: paint anyway. No fleet
80
+ // chat is configured, so the destination is the live turn's chat AND its
81
+ // forum topic (stamp-turn fallback, #3458) — NOT General, NOT the owner DM.
82
+ const out = decide({
83
+ origin: null,
84
+ cardExists: false,
85
+ priorDeferrals: MAX - 1,
86
+ fleetChatId: '',
87
+ stampChatId: ORIGIN_CHAT,
88
+ stampThreadId: ORIGIN_THREAD,
89
+ })
90
+ expect(out.action).toBe('paint')
91
+ if (out.action === 'paint') {
92
+ expect(out.chatId).toBe(ORIGIN_CHAT)
93
+ expect(out.threadId).toBe(ORIGIN_THREAD) // forum topic carried, not General
94
+ expect(out.chatId).not.toBe(OWNER_DM)
95
+ expect(out.exhausted).toBe(true)
96
+ expect(out.deferrals).toBe(MAX)
97
+ }
98
+ })
99
+
100
+ it('exhausted defer with NO stamp chat → owner DM (durable floor), flagged as fallback', () => {
101
+ const out = decide({
102
+ origin: null,
103
+ cardExists: false,
104
+ priorDeferrals: MAX - 1,
105
+ fleetChatId: '',
106
+ stampChatId: undefined,
107
+ })
108
+ expect(out.action).toBe('paint')
109
+ if (out.action === 'paint') {
110
+ expect(out.chatId).toBe(OWNER_DM)
111
+ expect(out.ownerDmFallback).toBe(true)
112
+ expect(out.exhausted).toBe(true)
113
+ }
114
+ })
115
+
116
+ it('(d) existing card always PAINTS (updates proceed) even while unlinked', () => {
117
+ const out = decide({
118
+ origin: null,
119
+ cardExists: true,
120
+ priorDeferrals: 0,
121
+ fleetChatId: ORIGIN_CHAT,
122
+ })
123
+ expect(out.action).toBe('paint')
124
+ if (out.action === 'paint') {
125
+ expect(out.chatId).toBe(ORIGIN_CHAT)
126
+ expect(out.deferrals).toBe(0)
127
+ }
128
+ })
129
+
130
+ it('fleet chat wins over the stamp fallback when configured', () => {
131
+ const out = decide({
132
+ origin: null,
133
+ cardExists: true, // paint (not defer) so we observe the route
134
+ fleetChatId: ORIGIN_CHAT,
135
+ stampChatId: '999999',
136
+ stampThreadId: 7,
137
+ })
138
+ expect(out.action).toBe('paint')
139
+ if (out.action === 'paint') {
140
+ expect(out.chatId).toBe(ORIGIN_CHAT)
141
+ // Fleet-chat route carries no stamp thread (usingStampFallback is false).
142
+ expect(out.threadId).toBeUndefined()
143
+ }
144
+ })
145
+ })
146
+
147
+ // ─── Integration: the real feed, driven by the real decision ──────────────────
148
+
149
+ interface SentRecord {
150
+ chatId: string
151
+ threadId: number | undefined
152
+ }
153
+
154
+ function makeRecordingBot(sent: SentRecord[]): BotApiForWorkerFeed {
155
+ let nextId = 1000
156
+ return {
157
+ async sendMessage(chatId, _text, opts) {
158
+ sent.push({
159
+ chatId,
160
+ threadId: (opts?.message_thread_id as number | undefined) ?? undefined,
161
+ })
162
+ return { message_id: nextId++ }
163
+ },
164
+ async editMessageText() {
165
+ return {}
166
+ },
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Faithful model of the gateway `onProgress` worker-feed block: run the REAL
172
+ * `decideWorkerFeedDestination` and — only when it says paint — call the real
173
+ * feed with the resolved chat/thread. `resolveOrigin` mimics
174
+ * `resolveSubagentOriginChat` (null until the backfill links the row).
175
+ */
176
+ function workerFeedTick(
177
+ feed: ReturnType<typeof createWorkerActivityFeed>,
178
+ deferrals: Map<string, number>,
179
+ agentId: string,
180
+ resolveOrigin: () => { chatId: string; threadId?: number } | null,
181
+ view: WorkerActivityView,
182
+ // The stamp-turn fallback used when no fleet chat is configured. Mirrors the
183
+ // gateway's exhausted-defer paint source (stampTurn.sessionChatId / thread).
184
+ stamp: { chatId?: string; threadId?: number } = { chatId: OWNER_DM, threadId: undefined },
185
+ ): void {
186
+ const dest = decideWorkerFeedDestination({
187
+ origin: resolveOrigin(),
188
+ cardExists: feed.has(agentId),
189
+ priorDeferrals: deferrals.get(agentId) ?? 0,
190
+ maxDeferrals: MAX,
191
+ fleetChatId: '',
192
+ stampChatId: stamp.chatId,
193
+ stampThreadId: stamp.threadId,
194
+ ownerDm: OWNER_DM,
195
+ })
196
+ if (dest.action === 'defer') {
197
+ deferrals.set(agentId, dest.deferrals)
198
+ return
199
+ }
200
+ deferrals.delete(agentId)
201
+ void feed.update(agentId, dest.chatId, view, dest.threadId)
202
+ }
203
+
204
+ describe('worker-feed origin race — outcome: no owner-DM card', () => {
205
+ const runningView = (elapsedMs: number): WorkerActivityView => ({
206
+ description: 'racing worker',
207
+ lastTool: 'Read',
208
+ toolCount: 1,
209
+ latestSummary: 'reading files',
210
+ elapsedMs,
211
+ state: 'running',
212
+ model: 'sonnet',
213
+ totalTokens: 10,
214
+ })
215
+
216
+ it('defers the racing first tick, then paints the card in the ORIGIN topic — never the DM', async () => {
217
+ let now = 0
218
+ const sent: SentRecord[] = []
219
+ const feed = createWorkerActivityFeed({
220
+ bot: makeRecordingBot(sent),
221
+ now: () => now,
222
+ firstPaintMinMs: 0, // paint immediately once we do update
223
+ minEditIntervalMs: 0,
224
+ })
225
+ const deferrals = new Map<string, number>()
226
+ const agentId = 'a37ad7639ae61476c'
227
+
228
+ // The backfill has NOT linked the row yet: origin resolves to null.
229
+ let linked = false
230
+ const resolveOrigin = () =>
231
+ linked ? { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD } : null
232
+
233
+ // First progress tick — races the backfill (origin unresolved).
234
+ workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(1000))
235
+ await Promise.resolve()
236
+
237
+ // OUTCOME: nothing sent. In particular, no card in the owner DM.
238
+ expect(sent).toHaveLength(0)
239
+ expect(deferrals.get(agentId)).toBe(1)
240
+ expect(feed.has(agentId)).toBe(false)
241
+
242
+ // ~3s later the backfill completes and links the row → origin resolves.
243
+ linked = true
244
+ now = 4000
245
+ workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(4000))
246
+ await Promise.resolve()
247
+ await Promise.resolve()
248
+
249
+ // OUTCOME: the card is created in the ORIGIN supergroup + forum topic,
250
+ // and NEVER in the owner DM.
251
+ expect(sent.length).toBeGreaterThanOrEqual(1)
252
+ for (const s of sent) {
253
+ expect(s.chatId).not.toBe(OWNER_DM)
254
+ }
255
+ expect(sent.some((s) => s.chatId === ORIGIN_CHAT && s.threadId === ORIGIN_THREAD)).toBe(true)
256
+ // Defer state cleared once linked.
257
+ expect(deferrals.has(agentId)).toBe(false)
258
+ })
259
+
260
+ it('bounded fallback: paints anyway if the backfill never links (no infinite defer)', async () => {
261
+ let now = 0
262
+ const sent: SentRecord[] = []
263
+ const feed = createWorkerActivityFeed({
264
+ bot: makeRecordingBot(sent),
265
+ now: () => now,
266
+ firstPaintMinMs: 0,
267
+ minEditIntervalMs: 0,
268
+ })
269
+ const deferrals = new Map<string, number>()
270
+ const agentId = 'neverlinks00000'
271
+ const resolveOrigin = () => null // backfill permanently broken
272
+
273
+ for (let i = 0; i < MAX; i++) {
274
+ now = i * 1000
275
+ workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now))
276
+ await Promise.resolve()
277
+ }
278
+ await Promise.resolve()
279
+
280
+ // The first MAX-1 ticks deferred; the MAX-th painted. The universal-liveness
281
+ // contract wins over infinite silence, so a card IS eventually created —
282
+ // here to the owner DM (the only resort when origin never resolves and no
283
+ // stamp chat is set).
284
+ expect(sent.length).toBeGreaterThanOrEqual(1)
285
+ expect(sent.every((s) => s.chatId === OWNER_DM)).toBe(true)
286
+ })
287
+
288
+ it('exhausted-defer (never-backfill) fallback lands in the origin FORUM THREAD, not General (#3458)', async () => {
289
+ // The backfill never links, so origin stays null through the bounded cap.
290
+ // In a forum supergroup the gateway paints using the live turn's chat AND
291
+ // its forum-topic id (stampTurn.sessionChatId / .sessionThreadId). Before
292
+ // the fix the thread was dropped and the card landed in General (thread
293
+ // undefined). Assert the thread id is carried to the painted card.
294
+ let now = 0
295
+ const sent: SentRecord[] = []
296
+ const feed = createWorkerActivityFeed({
297
+ bot: makeRecordingBot(sent),
298
+ now: () => now,
299
+ firstPaintMinMs: 0,
300
+ minEditIntervalMs: 0,
301
+ })
302
+ const deferrals = new Map<string, number>()
303
+ const agentId = 'neverlinks-forum'
304
+ const resolveOrigin = () => null // backfill permanently broken
305
+ // Live turn's chat + forum topic — the exhausted-defer fallback source.
306
+ const liveFallback = { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD }
307
+
308
+ for (let i = 0; i < MAX; i++) {
309
+ now = i * 1000
310
+ workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now), liveFallback)
311
+ await Promise.resolve()
312
+ }
313
+ await Promise.resolve()
314
+
315
+ // OUTCOME: a card painted, in the origin supergroup AND its forum topic —
316
+ // the thread id survived the never-backfill fallback (not General).
317
+ expect(sent.length).toBeGreaterThanOrEqual(1)
318
+ expect(sent.every((s) => s.chatId === ORIGIN_CHAT)).toBe(true)
319
+ expect(sent.every((s) => s.threadId === ORIGIN_THREAD)).toBe(true)
320
+ })
321
+ })