switchroom 0.21.10 → 0.21.12

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 (40) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +5 -2
  4. package/skills/dev-protocol/SKILL.md +14 -10
  5. package/skills/switchroom-release/SKILL.md +11 -5
  6. package/telegram-plugin/dist/gateway/gateway.js +385 -35
  7. package/telegram-plugin/format.ts +56 -0
  8. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  9. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  10. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  11. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  12. package/telegram-plugin/gateway/gateway.ts +61 -68
  13. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  14. package/telegram-plugin/gateway/outbound-send-path.ts +55 -1
  15. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  16. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  17. package/telegram-plugin/gateway/stream-render.ts +1 -1
  18. package/telegram-plugin/history.ts +21 -0
  19. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  20. package/telegram-plugin/render/html-fold.ts +22 -4
  21. package/telegram-plugin/render/parse.ts +9 -1
  22. package/telegram-plugin/render/render.ts +14 -13
  23. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  24. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  25. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  26. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  28. package/telegram-plugin/tests/history.test.ts +78 -0
  29. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  30. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  31. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +76 -3
  32. package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
  33. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  34. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  35. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  36. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  37. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  38. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  39. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  40. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -140,7 +140,7 @@ describe('wiring: message:rich_message dispatches through the coalescing path',
140
140
  // through `handleInboundCoalesced`.
141
141
  // ---------------------------------------------------------------------------
142
142
 
143
- const CHAT_ID = -1004223464247
143
+ const CHAT_ID = -1004444444444
144
144
  const USER_ID = 777
145
145
 
146
146
  function makeBotInfo() {
@@ -159,13 +159,13 @@ function makeBotInfo() {
159
159
 
160
160
  /** Forwarded rich (bot) message, no top-level text/caption — the live shape. */
161
161
  function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
162
- const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
162
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Sidekick', username: 'example_sidekick_bot' }
163
163
  return {
164
164
  update_id,
165
165
  message: {
166
166
  message_id: update_id,
167
- chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
168
- from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
167
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
168
+ from: { id: USER_ID, is_bot: false, first_name: 'Alice' },
169
169
  date: 1784837003,
170
170
  forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
171
171
  forward_from: originUser,
@@ -180,8 +180,8 @@ function makeTextUpdate(update_id: number, text: string): Update {
180
180
  update_id,
181
181
  message: {
182
182
  message_id: update_id,
183
- chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
184
- from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
183
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
184
+ from: { id: USER_ID, is_bot: false, first_name: 'Alice' },
185
185
  date: 1784837004,
186
186
  text,
187
187
  },
@@ -85,13 +85,13 @@ function buildHarness() {
85
85
  * forward_origin (modern) + legacy forward_from/forward_date siblings +
86
86
  * rich_message content, NO top-level text/caption. */
87
87
  function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
88
- const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
88
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Sidekick', username: 'example_sidekick_bot' }
89
89
  return {
90
90
  update_id,
91
91
  message: {
92
92
  message_id: 944,
93
- chat: { id: -1004223464247, type: 'supergroup', title: 'ProductOS', is_forum: true },
94
- from: { id: 777, is_bot: false, first_name: 'Ken' },
93
+ chat: { id: -1004444444444, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
94
+ from: { id: 777, is_bot: false, first_name: 'Alice' },
95
95
  date: 1784837003,
96
96
  forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
97
97
  forward_from: originUser,
@@ -142,12 +142,12 @@ describe('forwarded rich (bot) message — the row-944 regression oracle', () =>
142
142
  (update as unknown as { message: { forward_origin: never } }).message.forward_origin,
143
143
  )
144
144
  const meta = buildForwardOriginMeta([origin!])
145
- expect(meta.forwarded_from).toBe('Klanker (@meken_klanker_bot)')
145
+ expect(meta.forwarded_from).toBe('Sidekick (@example_sidekick_bot)')
146
146
  expect(meta.forwarded_from_type).toBe('user')
147
147
  expect(meta.forwarded_date).toBeDefined()
148
148
  // Trusted-lane separation (#3162): the delivered BODY carries no
149
149
  // provenance strings — those live only in the channel attrs.
150
- expect(delivered[0].text).not.toContain('Klanker')
150
+ expect(delivered[0].text).not.toContain('Sidekick')
151
151
  })
152
152
 
153
153
  it('a rich message with genuinely no extractable text still yields an honest turn', async () => {
@@ -175,7 +175,7 @@ describe('forwarded rich (bot) message — the row-944 regression oracle', () =>
175
175
  message: {
176
176
  message_id: 950,
177
177
  chat: { id: 777, type: 'private' },
178
- from: { id: 777, is_bot: false, first_name: 'Ken' },
178
+ from: { id: 777, is_bot: false, first_name: 'Alice' },
179
179
  date: 1784837100,
180
180
  rich_message: { blocks: [{ type: 'heading', size: 2, text: 'Release plan' }, { type: 'paragraph', text: 'Ship Friday.' }] },
181
181
  },
@@ -196,14 +196,14 @@ describe('working shapes stay unchanged (no regression on existing forwards)', (
196
196
  msg.forward_origin = {
197
197
  type: 'user',
198
198
  date: 1_700_000_000,
199
- sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
199
+ sender_user: { id: 424242, is_bot: false, first_name: 'Alice', last_name: 'Example' },
200
200
  }
201
201
  await bot.handleUpdate(update)
202
202
 
203
203
  expect(delivered).toHaveLength(1)
204
204
  expect(delivered[0]).toMatchObject({ via: 'text', text: 'here is the brief I forwarded' })
205
205
  const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
206
- expect(meta.forwarded_from).toBe('Ken Thompson')
206
+ expect(meta.forwarded_from).toBe('Alice Example')
207
207
  expect(meta.forwarded_date).toBeDefined()
208
208
  })
209
209
 
@@ -546,6 +546,84 @@ describe('hasOutboundDeliveredSince', () => {
546
546
  expect(hasOutboundDeliveredSince('-100', openedAt, 6, 1)).toBe(false)
547
547
  })
548
548
  })
549
+
550
+ // #4681 — the optional UPPER bound. The obligation escalate branch's reroute
551
+ // fallback is licensed by an `EXPLICIT_OVERRIDDEN` record, which is evidence
552
+ // about ONE instant; an open-ended forward query lets unrelated traffic in the
553
+ // routed topic, arriving arbitrarily later, read as that record's answer and
554
+ // close a genuinely unanswered obligation in silence.
555
+ describe('untilMs parameter (the reroute fallback upper bound)', () => {
556
+ it('omitted, the query stays open-ended forward (pre-existing behaviour)', () => {
557
+ const openedAt = 1_000_000 * 1000
558
+ recordOutbound({
559
+ chat_id: '-100',
560
+ thread_id: null,
561
+ message_ids: [10],
562
+ texts: [SUBSTANTIVE],
563
+ ts: 1_090_000, // 90_000s later
564
+ })
565
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
566
+ })
567
+
568
+ it('excludes a row delivered AFTER the bound', () => {
569
+ const openedAt = 1_000_000 * 1000
570
+ recordOutbound({
571
+ chat_id: '-100',
572
+ thread_id: null,
573
+ message_ids: [10],
574
+ texts: [SUBSTANTIVE],
575
+ ts: 1_000_090, // 90s after the lower bound
576
+ })
577
+ // Inside an 18.5s window it cannot be this record's answer.
578
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 18_500)).toBe(
579
+ false,
580
+ )
581
+ })
582
+
583
+ it('includes a row inside the bound, and the boundary second itself', () => {
584
+ const openedAt = 1_000_000 * 1000
585
+ recordOutbound({
586
+ chat_id: '-100',
587
+ thread_id: null,
588
+ message_ids: [10],
589
+ texts: [SUBSTANTIVE],
590
+ ts: 1_000_018, // 18s after — inside an 18.5s window
591
+ })
592
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 18_500)).toBe(
593
+ true,
594
+ )
595
+ // The bound is CEILed to whole seconds, so a row written in the second the
596
+ // bound falls inside is admitted — rounding never drops a real answer.
597
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 17_600)).toBe(
598
+ true,
599
+ )
600
+ })
601
+
602
+ it('composes with the thread filter (parameter binding order)', () => {
603
+ // The `ts <= ?` placeholder is appended BEFORE `thread_id = ?`; if the two
604
+ // params were pushed in the wrong order the thread id would land in the
605
+ // timestamp comparison and this would silently misbehave.
606
+ const openedAt = 1_000_000 * 1000
607
+ recordOutbound({
608
+ chat_id: '-100',
609
+ thread_id: 5,
610
+ message_ids: [10],
611
+ texts: [SUBSTANTIVE],
612
+ ts: 1_000_010,
613
+ })
614
+ recordOutbound({
615
+ chat_id: '-100',
616
+ thread_id: 6,
617
+ message_ids: [11],
618
+ texts: [SUBSTANTIVE],
619
+ ts: 1_000_090,
620
+ })
621
+ const until = openedAt + 18_500
622
+ expect(hasOutboundDeliveredSince('-100', openedAt, 5, 200, until)).toBe(true)
623
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6, 200, until)).toBe(false) // too late
624
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6, 200)).toBe(true) // unbounded finds it
625
+ })
626
+ })
549
627
  })
550
628
 
551
629
  // Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
@@ -26,7 +26,12 @@ const gatewaySrc =
26
26
  // P7 PR-10 (#2996): the InboundMessage envelope assembly (origin_turn_id,
27
27
  // topic_scope, meta lane) moved verbatim into gateway/inbound-router.ts
28
28
  // (buildInboundEnvelope) — include it in the scraped corpus.
29
- readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8')
29
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8') +
30
+ '\n' +
31
+ // The reply-route telemetry line (via/RECOVERED/UNROUTED/MISROUTE_RISK/
32
+ // CROSS_CHAT_ANCHOR_DROPPED) moved verbatim out of resolveAnswerThreadWithLog
33
+ // into the pure formatReplyRouteLog (#2996 line ratchet) — scrape it too.
34
+ readFileSync(resolve(__dirname, '..', 'gateway', 'reply-route-log.ts'), 'utf-8')
30
35
  // #2996 P4-A: the enqueue handler (turn ctor: `const turnId = deriveTurnId`,
31
36
  // `rememberRecentTurn(next)`, `promoteQueuedStatus`) moved VERBATIM into
32
37
  // stream-render.ts with handleSessionEvent. Enqueue-seam assertions span both.
@@ -147,6 +152,29 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
147
152
  expect(gatewaySrc).toMatch(/via === 'quoted' \? ' QUOTED\(framework-origin\)' : ''/)
148
153
  })
149
154
 
155
+ it('a thread anchor from ANOTHER chat is dropped before it can be sent (cross-chat guard)', () => {
156
+ // The routing input carries the target + anchor chat ids, so the pure
157
+ // resolver can refuse a foreign anchor (Telegram 400 "message thread not
158
+ // found"). Behaviour is asserted in answer-thread-resolve.test.ts; this
159
+ // pins the WIRING so a refactor cannot silently stop passing them.
160
+ //
161
+ // NOT A SAFETY NET — read before relying on it. Like its ~20 siblings in
162
+ // this file, this is a source-text CALL-SITE RATCHET: it greps gateway.ts
163
+ // for the argument spellings. Mutating `isCrossChatAnchor` to
164
+ // `return false` leaves every assertion here GREEN (verified), because the
165
+ // call site is still spelled the same way. The behavioural oracles are
166
+ // `answer-thread-resolve.test.ts` (the pure predicate + precedence),
167
+ // `reply-route-log.test.ts` (the telemetry), and the
168
+ // `SWITCHROOM_TURN_ORIGIN_ROUTING=0` block in `send-reply-golden.test.ts`
169
+ // (the legacy branch, at the wire). Those are what fail on a broken guard.
170
+ const fn = gatewaySrc.split('function resolveAnswerThreadWithLog')[1]?.split('\nfunction ')[0] ?? ''
171
+ expect(fn).toMatch(/targetChatId: chatId/)
172
+ expect(fn).toMatch(/originChatId: originTurn\?\.sessionChatId/)
173
+ expect(fn).toMatch(/liveChatId: liveTurn\?\.sessionChatId/)
174
+ // And the dropped anchor is observable, with both chat ids on the line.
175
+ expect(gatewaySrc).toMatch(/CROSS_CHAT_ANCHOR_DROPPED\(target=/)
176
+ })
177
+
150
178
  it('the kill switch defaults ON and is independent of TURN_ORIGIN_ROUTING', () => {
151
179
  expect(gatewaySrc).toMatch(/SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'/)
152
180
  })
@@ -106,7 +106,13 @@ import {
106
106
  runOrphanedDbSweepTick,
107
107
  startOrphanedDbSweep,
108
108
  } from '../gateway/orphaned-db-sweep.js'
109
- import { GATEWAY_SIGNATURES } from '../../src/fleet-health/detect.js'
109
+ import { GATEWAY_SIGNATURES, detectGatewayFindings } from '../../src/fleet-health/detect.js'
110
+ import { mapSignal } from '../../src/fleet-health/mapping.js'
111
+
112
+ /** Severity the fleet-health ledger would book for a real sweep log. */
113
+ function ledgerSeverities(log: string): number[] {
114
+ return detectGatewayFindings('uat', log).findings.map((f) => mapSignal(f.signal).severity)
115
+ }
110
116
 
111
117
  /**
112
118
  * `/proc/self/fd` is Linux-only. Without this guard every detection test would
@@ -294,6 +300,13 @@ describe.skipIf(!onLinux)('orphaned-db-sweep — history.db recovery', () => {
294
300
  // alarm has no consumer.
295
301
  expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
296
302
 
303
+ // #4680 — but this tick RECOVERED: the reopen landed in the same tick and
304
+ // the writer self-check proved the handle durable. The ledger must book
305
+ // that as the informational lane, not a severity-3 silent-loss incident.
306
+ // Asserted against the REAL sweep output, so a wording change in either the
307
+ // emitter or the detector fails here instead of drifting silently.
308
+ expect(ledgerSeverities(log)).toEqual([1])
309
+
297
310
  // 7. THE FIX, half one: the deleted-inode handles are actually GONE. A
298
311
  // close that leaves un-finalized statements behind leaves them open
299
312
  // (measured), so this is the assertion that pins the hard close.
@@ -439,6 +452,9 @@ describe.skipIf(!onLinux)('orphaned-db-sweep — registry.db and unowned lanes',
439
452
  expect(log).toContain('registry.db')
440
453
  expect(log).toContain('RESTART')
441
454
  expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
455
+ // #4680 — the registry lane has NO in-process recovery, so it stays the
456
+ // severity-3 alarm. This is the half of the split that must not be lost.
457
+ expect(ledgerSeverities(log)).toEqual([3])
442
458
 
443
459
  // Detection ONLY: the raw handle is left alone, so its consumers (the
444
460
  // by-value `turnsDb` captures in gateway.ts) are never handed a closed DB.
@@ -1,9 +1,17 @@
1
1
  // Outcome tests for the content-loss regressions found in the raw-HTML
2
2
  // dialect fold (#4691, merged as fa9018a7, unreleased).
3
3
  //
4
- // Every assertion here FAILS on fa9018a7. They drive the real `renderOutbound`
5
- // (`parse` -> `renderSafe`), i.e. exactly what the live send path calls, so
6
- // they assert the WIRE OUTCOME rather than an internal code path.
4
+ // 29 of the 37 tests here fail on fa9018a7 — measured by running this exact
5
+ // file against that revision, not asserted. The other 8 pass on both and are
6
+ // guards: they pin behaviour the fix must not disturb. (An earlier revision of
7
+ // this header claimed "every assertion fails on fa9018a7"; at 31 tests the
8
+ // true figure was 23.) Five of the 29 — the `<pre>` fence-width cases — also
9
+ // fail on 6c120cee, the first fix's own merge commit, which emitted a
10
+ // hardcoded three-backtick fence.
11
+ //
12
+ // They drive the real `renderOutbound` (`parse` -> `renderSafe`), i.e. exactly
13
+ // what the live send path calls, so they assert the WIRE OUTCOME rather than
14
+ // an internal code path.
7
15
  //
8
16
  // The through-line: fa9018a7's degrade bucket DELETED any token it could not
9
17
  // fold or pass through. That is safe for markup but catastrophic for prose —
@@ -111,6 +119,71 @@ describe("MAJOR 1: `<pre>` folds to a fenced block, not a broken code span", ()
111
119
  expect(render("# a <pre>x</pre> b")).toBe("# a `x` b");
112
120
  expect(render("| h |\n| --- |\n| <pre>x</pre> |")).toContain("| `x` |");
113
121
  });
122
+
123
+ // ── Regression: the fold's fence must be WIDER than its own body ────────
124
+ // `buildPreBlock` emitted a hardcoded three-backtick delimiter as a `raw`
125
+ // node (verbatim wire passthrough — nothing downstream widens it). A `<pre>`
126
+ // whose body carried its own ``` therefore shipped THREE delimiters: the
127
+ // wire opens at #1, closes at #2, reads the remainder as prose, and #3 opens
128
+ // a fence that never terminates -> `can't find end of Pre entity` 400 and a
129
+ // whole-message plain-text resend. `fa9018a7` escaped the backticks instead
130
+ // — cosmetically worse, but wire-correct; this restores wire-correctness
131
+ // using the same width rule `renderCodeBlock` already applies.
132
+ it("widens the fence past a three-backtick run in the body", () => {
133
+ expect(render("<pre>\na\n```\nb\n</pre>")).toBe("````\na\n```\nb\n````");
134
+ });
135
+
136
+ it("widens the fence past a longer run than three", () => {
137
+ expect(render("<pre>\na\n````\nb\n</pre>")).toBe("`````\na\n````\nb\n`````");
138
+ expect(render("<pre>\na\n`````\nb\n</pre>")).toBe(
139
+ "``````\na\n`````\nb\n``````",
140
+ );
141
+ });
142
+
143
+ it("widens past a run that shares a line with other text", () => {
144
+ expect(render("<pre><code>x ``` y</code></pre>")).toBe(
145
+ "````\nx ``` y\n````",
146
+ );
147
+ });
148
+
149
+ it("still emits a plain three-backtick fence when the body has no run", () => {
150
+ // The widening must not inflate the common case.
151
+ expect(render("<pre>\nplain body\n</pre>")).toBe("```\nplain body\n```");
152
+ expect(render("<pre>\na ` b `` c\n</pre>")).toBe("```\na ` b `` c\n```");
153
+ });
154
+
155
+ it("keeps the language hint attached to the widened delimiter", () => {
156
+ expect(
157
+ render('<pre><code class="language-md">see ```\n</code></pre>'),
158
+ ).toBe("````md\nsee ```\n````");
159
+ });
160
+
161
+ it("emits a terminating fence for every inner backtick run length", () => {
162
+ // Outcome, not shape: whatever the body carries, the delimiter run must
163
+ // appear on exactly TWO lines (open and close) and be strictly longer than
164
+ // every run inside, so the wire can neither close it early nor leave it
165
+ // open. This is the property whose violation produced the 400.
166
+ for (const inner of ["```", "````", "`````", "` `` ```", "a```b", "```` x"]) {
167
+ const src = `<pre>\nhead\n${inner}\ntail\n</pre>`;
168
+ const out = render(src);
169
+ const lines = out.split("\n");
170
+ const delim = lines[0];
171
+ expect(delim, `open delimiter for ${JSON.stringify(inner)}`).toMatch(
172
+ /^`{3,}$/,
173
+ );
174
+ expect(lines[lines.length - 1], `close delimiter for ${inner}`).toBe(
175
+ delim,
176
+ );
177
+ // Every backtick run in the BODY is strictly shorter than the delimiter.
178
+ const body = lines.slice(1, -1).join("\n");
179
+ for (const run of body.match(/`+/g) ?? []) {
180
+ expect(
181
+ run.length,
182
+ `body run ${JSON.stringify(run)} vs delimiter ${delim}`,
183
+ ).toBeLessThan(delim.length);
184
+ }
185
+ }
186
+ });
114
187
  });
115
188
 
116
189
  describe("MAJOR 2: structural tags do not glue their content together", () => {
@@ -31,7 +31,7 @@
31
31
  * - structural: gateway constructs EXACTLY ONE OutboundDedupCache and the
32
32
  * extracted module constructs NONE (the re-`new` hard-fail rule)
33
33
  */
34
- import { describe, it, expect, afterEach } from 'vitest'
34
+ import { describe, it, expect, afterEach, vi } from 'vitest'
35
35
  import { GrammyError } from 'grammy'
36
36
  import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'
37
37
  import { tmpdir } from 'node:os'
@@ -209,6 +209,17 @@ function makeHarness(opts?: {
209
209
  failNext?: Partial<Record<string, unknown[]>>
210
210
  voicePlan?: VoiceOutPlan | null
211
211
  synth?: (plan: { ttsText: string }) => Promise<Uint8Array | null>
212
+ /** `SWITCHROOM_TURN_ORIGIN_ROUTING !== '0'` — the gateway's turn-origin
213
+ * routing kill switch. Default true (the shipped default); pass false to
214
+ * drive the legacy `resolveThreadId` branch. */
215
+ turnOriginRouting?: boolean
216
+ /** Backing store for the `resolveThreadId` stub's last-seen fallback — the
217
+ * real resolver (`gateway.ts`, `resolveThreadId`) is
218
+ * `if (explicit != null) return Number(explicit); return chatThreadMap.get(chat_id)`,
219
+ * so a case that cares about what a DROPPED anchor falls through TO must
220
+ * populate this. Omitted → empty map → the fallback misses, which is the
221
+ * behaviour every pre-existing case here assumes. */
222
+ chatThreadMap?: Map<string, number>
212
223
  }): Harness {
213
224
  const { api, calls } = makeFakeBot(opts?.failNext)
214
225
  const dedup = opts?.dedup ?? new OutboundDedupCache()
@@ -237,7 +248,7 @@ function makeHarness(opts?: {
237
248
  getCurrentTurn,
238
249
  getLastActiveTurnChatId: () => undefined,
239
250
  HISTORY_ENABLED: false,
240
- TURN_ORIGIN_ROUTING_ENABLED: true,
251
+ TURN_ORIGIN_ROUTING_ENABLED: opts?.turnOriginRouting ?? true,
241
252
  AUTOCLASSIFY_MIDTURN_SHADOW: false,
242
253
  MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
243
254
  MAX_CHUNK_LIMIT: 4096,
@@ -265,7 +276,12 @@ function makeHarness(opts?: {
265
276
  findTurnByOriginId: () => null,
266
277
  findTurnByQuotedMessageId: () => null,
267
278
  resolveAnswerThreadWithLog: (_c, explicit) => explicit,
268
- resolveThreadId: (_c, explicit) => (explicit != null ? Number(explicit) : undefined),
279
+ // Mirrors the real `resolveThreadId` (`gateway.ts`): explicit wins, else
280
+ // the chat's LAST-SEEN topic from `chatThreadMap`. The map is per-harness
281
+ // and chat-scoped, so a dropped cross-chat anchor falls through to the
282
+ // TARGET chat's own last-seen topic — never to the anchor chat's.
283
+ resolveThreadId: (c, explicit) =>
284
+ explicit != null ? Number(explicit) : (opts?.chatThreadMap?.get(c) ?? undefined),
269
285
  getLatestInboundMessageId: () => null,
270
286
  recordOutbound: () => effects.push('recordOutbound'),
271
287
  emissionAuthorityFor: () =>
@@ -2127,3 +2143,176 @@ describe('#4176 — a BACKGROUND SUB-AGENT reply can never silently edit over a
2127
2143
  expect(sendSrc).toContain('subagentCouldOwnReply,')
2128
2144
  })
2129
2145
  })
2146
+
2147
+ // ── cross-chat thread anchor, kill-switch branch (bug c) ──────────────────
2148
+ //
2149
+ // The default (`TURN_ORIGIN_ROUTING_ENABLED: true`) branch resolves the thread
2150
+ // through `resolveAnswerThreadWithLog`, which drops a wrong-chat anchor inside
2151
+ // `resolveAnswerThreadId`. The legacy branch (`SWITCHROOM_TURN_ORIGIN_ROUTING=0`)
2152
+ // does NOT go through that resolver at all — it passes the pinned turn's
2153
+ // `sessionThreadId` straight into `resolveThreadId`. When the pinned turn
2154
+ // belongs to a DIFFERENT chat (the routine multi-chat case: one sequential CLI,
2155
+ // a singleton live turn, replies fanning out to several chats) that hands chat
2156
+ // B's topic id to a send into chat A, and Telegram answers
2157
+ // `400 Bad Request: message thread not found` — the exact failure the guard
2158
+ // exists to prevent, still reachable behind the switch.
2159
+ //
2160
+ // These drive the REAL `sendReply` and assert on the `message_thread_id` the
2161
+ // send-path actually put on the wire, so they fail on the bug rather than on a
2162
+ // refactor of how the decision is spelled.
2163
+ //
2164
+ // What the drop resolves TO: dropping the anchor does NOT mean "no thread id".
2165
+ // The legacy branch still calls `resolveThreadId`, whose real body
2166
+ // (`gateway.ts`) is `explicit ?? chatThreadMap.get(chat_id)` — so a dropped
2167
+ // anchor falls through to the TARGET chat's own last-seen topic. That fallback
2168
+ // is chat-scoped, so it can only ever yield a topic id valid for chat A; the
2169
+ // class of failure the guard closes (chat B's topic on a send into chat A →
2170
+ // `400 message thread not found`) stays closed either way. Cases below cover
2171
+ // BOTH ends: an empty map (fallback misses → undefined) and a populated one
2172
+ // (fallback hits → chat A's topic).
2173
+ describe('kill switch SWITCHROOM_TURN_ORIGIN_ROUTING=0 still drops a cross-chat anchor', () => {
2174
+ const OTHER_CHAT = '2002'
2175
+
2176
+ /** A live turn pinned at executeReply entry, owned by `chatId`. */
2177
+ function turnFor(chatId: string, threadId: number | undefined): CurrentTurn {
2178
+ return {
2179
+ turnId: `turn-${chatId}`,
2180
+ sessionChatId: chatId,
2181
+ sessionThreadId: threadId,
2182
+ replyCalled: false,
2183
+ } as unknown as CurrentTurn
2184
+ }
2185
+
2186
+ const threadOnWire = (h: Harness): unknown =>
2187
+ h.calls.filter((c) => c.method === 'sendRichMessage')[0]!.opts.message_thread_id
2188
+
2189
+ it('a reply into chat A pinned to a chat-B forum turn does NOT carry chat B\'s ' +
2190
+ 'topic id — with no last-seen topic for chat A it resolves to no thread', async () => {
2191
+ // Empty `chatThreadMap`: the post-drop `resolveThreadId` fallback misses,
2192
+ // so the wire carries no thread. This is the fallback-misses END of the
2193
+ // behaviour, not the whole of it — see the populated-map case below.
2194
+ const h = makeHarness({ turnOriginRouting: false })
2195
+ const res = await sendReply(
2196
+ h.deps,
2197
+ req('The answer to the question asked in chat A.', {}, turnFor(OTHER_CHAT, 77)),
2198
+ )
2199
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
2200
+ expect(sends).toHaveLength(1)
2201
+ expect(sends[0]!.chat_id).toBe(CHAT)
2202
+ // Pre-fix this is 77 — chat B's topic id on a send into chat A.
2203
+ expect(threadOnWire(h)).toBeUndefined()
2204
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
2205
+ })
2206
+
2207
+ it('the dropped anchor falls through to the TARGET chat\'s last-seen topic, ' +
2208
+ 'not to no-thread: chat A last saw topic 12 → the send lands in A/12', async () => {
2209
+ // The production shape the empty-map case above cannot show: the agent is
2210
+ // live in supergroup B (topic 77) and replies into forum chat A, whose
2211
+ // `chatThreadMap` last saw topic 12. Dropping B's anchor hands the decision
2212
+ // to `resolveThreadId`, which is chat-scoped — so the send lands in A's own
2213
+ // topic 12. 77 on the wire here would be the `400 message thread not found`
2214
+ // the guard exists to prevent.
2215
+ const h = makeHarness({
2216
+ turnOriginRouting: false,
2217
+ chatThreadMap: new Map([[CHAT, 12], [OTHER_CHAT, 77]]),
2218
+ })
2219
+ const res = await sendReply(
2220
+ h.deps,
2221
+ req('Late answer into chat A.', {}, turnFor(OTHER_CHAT, 77)),
2222
+ )
2223
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
2224
+ expect(sends).toHaveLength(1)
2225
+ expect(sends[0]!.chat_id).toBe(CHAT)
2226
+ expect(threadOnWire(h)).toBe(12)
2227
+ expect(threadOnWire(h)).not.toBe(77)
2228
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
2229
+ })
2230
+
2231
+ it('an explicit message_thread_id from the model does NOT rescue a cross-chat ' +
2232
+ 'anchor — but it is still honoured on its own', async () => {
2233
+ // Explicit wins in the legacy branch, so it must survive untouched.
2234
+ const h = makeHarness({ turnOriginRouting: false })
2235
+ await sendReply(
2236
+ h.deps,
2237
+ req('Explicit thread.', { message_thread_id: '31' }, turnFor(OTHER_CHAT, 77)),
2238
+ )
2239
+ expect(threadOnWire(h)).toBe(31)
2240
+ })
2241
+
2242
+ it('BACK-COMPAT: a SAME-chat forum turn still lends its topic id (the guard ' +
2243
+ 'must not over-fire and strip legitimate topic routing)', async () => {
2244
+ const h = makeHarness({ turnOriginRouting: false })
2245
+ await sendReply(h.deps, req('Same-chat topic reply.', {}, turnFor(CHAT, 77)))
2246
+ expect(threadOnWire(h)).toBe(77)
2247
+ })
2248
+
2249
+ it('BACK-COMPAT: no pinned turn at all → no thread id, unchanged', async () => {
2250
+ const h = makeHarness({ turnOriginRouting: false })
2251
+ await sendReply(h.deps, req('Orphaned proactive send.'))
2252
+ expect(threadOnWire(h)).toBeUndefined()
2253
+ })
2254
+
2255
+ // ── telemetry parity with the flag-on path ──────────────────────────────
2256
+ //
2257
+ // The flag-ON branch emits `CROSS_CHAT_ANCHOR_DROPPED(...)` through
2258
+ // `resolveAnswerThreadWithLog` → `formatReplyRouteLog`. Without the same line
2259
+ // here, a deployment running `SWITCHROOM_TURN_ORIGIN_ROUTING=0` silently
2260
+ // loses the audit trail the rest of this fix is built on: the drop still
2261
+ // happens, but nothing records that it did. The line is built by the SAME
2262
+ // `formatReplyRouteLog` — there is exactly one authoritative implementation.
2263
+ describe('telemetry: the legacy-branch drop is not silent', () => {
2264
+ const captureStderr = (): { lines: string[]; restore: () => void } => {
2265
+ const lines: string[] = []
2266
+ const spy = vi
2267
+ .spyOn(process.stderr, 'write')
2268
+ .mockImplementation(((chunk: unknown) => {
2269
+ lines.push(String(chunk))
2270
+ return true
2271
+ }) as unknown as typeof process.stderr.write)
2272
+ return { lines, restore: () => spy.mockRestore() }
2273
+ }
2274
+
2275
+ it('emits CROSS_CHAT_ANCHOR_DROPPED naming both chats and what it routed to', async () => {
2276
+ const h = makeHarness({
2277
+ turnOriginRouting: false,
2278
+ chatThreadMap: new Map([[CHAT, 12]]),
2279
+ })
2280
+ const cap = captureStderr()
2281
+ try {
2282
+ await sendReply(h.deps, req('Late answer into chat A.', {}, turnFor(OTHER_CHAT, 77)))
2283
+ } finally {
2284
+ cap.restore()
2285
+ }
2286
+ const line = cap.lines.find((l) => l.includes('CROSS_CHAT_ANCHOR_DROPPED('))
2287
+ expect(line).toBeDefined()
2288
+ // Same marker shape the flag-on path emits: target chat, the anchor's
2289
+ // chat + topic, and the thread the send actually routed to.
2290
+ expect(line).toContain('reply-route surface=reply')
2291
+ expect(line).toContain(`chat=${CHAT}`)
2292
+ expect(line).toContain(`live_chat=${OTHER_CHAT}`)
2293
+ expect(line).toContain('live_thread=77')
2294
+ expect(line).toContain('routed→12')
2295
+ })
2296
+
2297
+ // NEGATIVE CONTROL (green against the pre-fix code by construction — the
2298
+ // pre-fix branch emits nothing at all). It earns its place under mutation:
2299
+ // asserting on the whole `reply-route` line rather than just the marker is
2300
+ // what makes it fail an UNCONDITIONAL emit, the plausible wrong shape here.
2301
+ // (Asserting only "no CROSS_CHAT_ANCHOR_DROPPED" would NOT — the formatter
2302
+ // omits the marker for a same-chat anchor anyway, so that weaker assertion
2303
+ // passes for a conditional and an unconditional emit alike.) The legacy
2304
+ // branch is a kill-switch path: it must stay byte-silent on the ordinary
2305
+ // send it exists to serve, and only speak when it drops something.
2306
+ it('stays silent on a SAME-chat anchor — no drop, and no reply-route line at all', async () => {
2307
+ const h = makeHarness({ turnOriginRouting: false })
2308
+ const cap = captureStderr()
2309
+ try {
2310
+ await sendReply(h.deps, req('Same-chat topic reply.', {}, turnFor(CHAT, 77)))
2311
+ } finally {
2312
+ cap.restore()
2313
+ }
2314
+ expect(cap.lines.some((l) => l.includes('CROSS_CHAT_ANCHOR_DROPPED'))).toBe(false)
2315
+ expect(cap.lines.some((l) => l.includes('reply-route surface='))).toBe(false)
2316
+ })
2317
+ })
2318
+ })
@@ -404,7 +404,7 @@ describe('unified reconcile path — pin-rights 400 must never crash (marko 2026
404
404
  // rejection to the process.
405
405
  const next = await reconcilePin({
406
406
  api,
407
- chatId: '-1003831053471',
407
+ chatId: '-1001234567890',
408
408
  prevState: null,
409
409
  desired: { pinned: true, messageId: 4 },
410
410
  onError: (phase, err) =>
@@ -437,7 +437,7 @@ describe('unified reconcile path — pin-rights 400 must never crash (marko 2026
437
437
  // Fire-and-forget, exactly as the gateway's auto-pin callsites do.
438
438
  void reconcilePin({
439
439
  api,
440
- chatId: '-1003831053471',
440
+ chatId: '-1001234567890',
441
441
  prevState: null,
442
442
  desired: { pinned: true, messageId: 4 },
443
443
  onError: () => {},
@@ -182,7 +182,7 @@ describe('buildSubagentHandbackInbound — meta.chat_id turn registration (msg-6
182
182
  it('carries the origin chat as meta.chat_id so the handback turn mints a turn atom', () => {
183
183
  const inbound = buildSubagentHandbackInbound({
184
184
  ctx: {
185
- chatId: '-1004223464247',
185
+ chatId: '-1004444444444',
186
186
  threadId: 77,
187
187
  taskDescription: 'Topic-dispatched work',
188
188
  resultText: 'done',
@@ -190,7 +190,7 @@ describe('buildSubagentHandbackInbound — meta.chat_id turn registration (msg-6
190
190
  },
191
191
  nowMs: FIXED_NOW,
192
192
  })
193
- expect(inbound.meta.chat_id).toBe('-1004223464247')
193
+ expect(inbound.meta.chat_id).toBe('-1004444444444')
194
194
  // The thread carrier must still ride alongside — chat_id + thread_id is
195
195
  // the full origin surface the minted turn (and any worker dispatched
196
196
  // inside it) inherits.
@@ -364,7 +364,7 @@ describe('buildSubagentProgressInbound — meta.chat_id turn registration (msg-6
364
364
  it('carries the origin chat as meta.chat_id', () => {
365
365
  const inbound = buildSubagentProgressInbound({
366
366
  ctx: {
367
- chatId: '-1004223464247',
367
+ chatId: '-1004444444444',
368
368
  threadId: 77,
369
369
  subagentJsonlId: 'stem1',
370
370
  taskDescription: 'Topic work',
@@ -375,7 +375,7 @@ describe('buildSubagentProgressInbound — meta.chat_id turn registration (msg-6
375
375
  },
376
376
  nowMs: 1_700_000_000_000,
377
377
  })
378
- expect(inbound.meta.chat_id).toBe('-1004223464247')
378
+ expect(inbound.meta.chat_id).toBe('-1004444444444')
379
379
  expect(inbound.meta.message_thread_id).toBe('77')
380
380
  })
381
381
  })