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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -2
- package/skills/dev-protocol/SKILL.md +14 -10
- package/skills/switchroom-release/SKILL.md +11 -5
- package/telegram-plugin/dist/gateway/gateway.js +385 -35
- package/telegram-plugin/format.ts +56 -0
- package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
- package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
- package/telegram-plugin/gateway/gateway.ts +61 -68
- package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +55 -1
- package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
- package/telegram-plugin/gateway/reply-route-log.ts +118 -0
- package/telegram-plugin/gateway/stream-render.ts +1 -1
- package/telegram-plugin/history.ts +21 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
- package/telegram-plugin/render/html-fold.ts +22 -4
- package/telegram-plugin/render/parse.ts +9 -1
- package/telegram-plugin/render/render.ts +14 -13
- package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
- package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
- package/telegram-plugin/tests/history.test.ts +78 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +76 -3
- package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
- package/telegram-plugin/tests/status-pin.test.ts +2 -2
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/telegram-format.test.ts +52 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- 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 = -
|
|
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: '
|
|
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: '
|
|
168
|
-
from: { id: USER_ID, is_bot: false, first_name: '
|
|
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: '
|
|
184
|
-
from: { id: USER_ID, is_bot: false, first_name: '
|
|
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: '
|
|
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: -
|
|
94
|
-
from: { id: 777, is_bot: false, first_name: '
|
|
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('
|
|
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('
|
|
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: '
|
|
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: '
|
|
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('
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// they
|
|
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
|
-
|
|
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: '-
|
|
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: '-
|
|
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: '-
|
|
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('-
|
|
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: '-
|
|
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('-
|
|
378
|
+
expect(inbound.meta.chat_id).toBe('-1004444444444')
|
|
379
379
|
expect(inbound.meta.message_thread_id).toBe('77')
|
|
380
380
|
})
|
|
381
381
|
})
|