switchroom 0.14.67 → 0.14.69

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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Multi-topic routing stress (channel) — two questions in TWO forum topics
3
+ * back-to-back, assert each answer lands in ITS OWN topic with no cross-bleed.
4
+ *
5
+ * This exercises live the failure class behind #2137 ("two questions in two
6
+ * forum topics → both answers in one topic, the other unanswered") and the
7
+ * #2166 late-reply topic recovery — the exact "handling messages in multiple
8
+ * channels" concern. One Claude CLI owns every topic via a singleton
9
+ * currentTurn, so a late/misrouted reply can land in the wrong topic.
10
+ *
11
+ * The test supergroup has General (message_thread_id omitted → observed
12
+ * threadId undefined) plus a real topic (thread=5). mtcute sends into a topic
13
+ * via messageThreadId→replyTo and reads each observed message's threadId, so we
14
+ * can prove WHICH topic each answer landed in. Self-skips green without
15
+ * SWITCHROOM_UAT_CHAT_ID or an unresolvable supergroup (uat/** is non-gating).
16
+ *
17
+ * mtcute caveat: it can't enumerate topics, so the topic id is pinned (5, the
18
+ * active non-General topic in the test supergroup). If that topic is ever
19
+ * deleted the send 400s and the test skips with a clear message.
20
+ */
21
+ import { describe, it, expect, beforeAll } from "vitest";
22
+ import { spinUp, type Scenario } from "../harness.js";
23
+ import { isWorkerFeedMessage, isActivityFeedMessage } from "../assertions.js";
24
+ import type { ObservedMessage } from "../driver.js";
25
+
26
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
27
+ const TOPIC_THREAD = 5; // a real non-General topic in the test supergroup
28
+
29
+ interface Hit {
30
+ text: string;
31
+ threadId: number | undefined;
32
+ messageId: number;
33
+ at: number;
34
+ }
35
+
36
+ describe("uat: multi-topic routing — two topics back-to-back, no answer bleed", () => {
37
+ let sc: Scenario | null = null;
38
+ let postable = false;
39
+
40
+ beforeAll(async () => {
41
+ if (!Number.isFinite(SUPERGROUP_ID)) {
42
+ console.warn("[uat] SWITCHROOM_UAT_CHAT_ID unset — skipping multi-topic routing");
43
+ return;
44
+ }
45
+ sc = await spinUp({ agent: "test-harness" });
46
+ await sc.driver.primeDialogs();
47
+ postable = await sc.driver.canResolve(SUPERGROUP_ID);
48
+ if (!postable) console.warn(`[uat] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
49
+ });
50
+
51
+ it("topic-5 Q and General Q are each answered IN THEIR OWN topic (no swap, no drop)", async () => {
52
+ if (sc == null || !postable) return; // self-skip green
53
+ const driver = sc.driver;
54
+ const driverUserId = sc.driverUserId;
55
+ await driver.primeDialogs();
56
+
57
+ // Distinct, unambiguous answers so we can tell the two replies apart by
58
+ // content and check the topic each landed in. 7+7=14 (topic 5), 40+2=42
59
+ // (General) — no digit overlap, no accidental substring match.
60
+ const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
61
+ let topic5: Hit | undefined;
62
+ let general: Hit | undefined;
63
+
64
+ try {
65
+ await driver.sendText(
66
+ SUPERGROUP_ID,
67
+ "Reply with only the number and nothing else: what is 7 + 7?",
68
+ { messageThreadId: TOPIC_THREAD },
69
+ );
70
+ } catch (err) {
71
+ console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
72
+ return;
73
+ }
74
+ // Back-to-back (the #2137 bleed trigger): General question immediately after.
75
+ await driver.sendText(
76
+ SUPERGROUP_ID,
77
+ "Reply with only the number and nothing else: what is 40 + 2?",
78
+ );
79
+
80
+ const deadline = Date.now() + 120_000;
81
+ while (Date.now() < deadline && !(topic5 && general)) {
82
+ const next = await Promise.race([
83
+ iter.next(),
84
+ new Promise<{ done: true; value: undefined }>((r) =>
85
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
86
+ ),
87
+ ]);
88
+ if (next.done || next.value == null) break;
89
+ const m = next.value as ObservedMessage;
90
+ if (m.senderUserId === driverUserId) continue; // our own sends
91
+ if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue; // status surfaces, not answers
92
+ const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
93
+ if (topic5 == null && /(^|\D)14(\D|$)/.test(m.text)) topic5 = hit;
94
+ if (general == null && /(^|\D)42(\D|$)/.test(m.text)) general = hit;
95
+ }
96
+ void iter.return?.();
97
+
98
+ console.log(
99
+ `[multitopic] topic5(7+7=14)=${topic5 ? `thread=${topic5.threadId ?? "-"} msg=${topic5.messageId}` : "MISSING"} ` +
100
+ `general(40+2=42)=${general ? `thread=${general.threadId ?? "-"} msg=${general.messageId}` : "MISSING"}`,
101
+ );
102
+
103
+ // Invariant 1: BOTH questions were answered (no drop — the #2137 "other
104
+ // topic unanswered" half).
105
+ expect(topic5, "topic-5 question (7+7=14) was never answered").toBeDefined();
106
+ expect(general, "General question (40+2=42) was never answered").toBeDefined();
107
+
108
+ // Invariant 2: each answer landed in ITS OWN topic (no bleed/swap). The
109
+ // topic-5 answer carries threadId=5; the General answer omits the thread.
110
+ expect(topic5!.threadId).toBe(TOPIC_THREAD);
111
+ expect(general!.threadId).toBeUndefined();
112
+ }, 150_000);
113
+
114
+ it("a SLOW topic-5 turn whose answer lands LATE (after a fast General turn) still routes to topic-5", async () => {
115
+ if (sc == null || !postable) return; // self-skip green
116
+ const { driver, driverUserId } = sc;
117
+ await driver.primeDialogs();
118
+
119
+ // The #2137 trigger: topic-5 turn is still working when General's turn
120
+ // arrives, so the singleton currentTurn flips to General. The slow topic-5
121
+ // answer then lands AFTER General's — it must STILL route to topic-5 (via
122
+ // origin_turn_id / the #2166 late-reply recovery), not bleed into General.
123
+ const iter = driver.observeMessages(SUPERGROUP_ID)[Symbol.asyncIterator]();
124
+ let slowHit: Hit | undefined; // topic-5, distinctive token
125
+ let fastHit: Hit | undefined; // General, the number 42
126
+
127
+ try {
128
+ await driver.sendText(
129
+ SUPERGROUP_ID,
130
+ "Do this slowly, ONE step at a time with a brief note on each (I want to see you work): run uname -a, then nproc, " +
131
+ "then lscpu, then cat /proc/cpuinfo | grep -c processor, then free -h, then df -h. After all six, tell me how many " +
132
+ "CPU cores this machine has and end your reply with the exact token CORESDONE on its own line.",
133
+ { messageThreadId: TOPIC_THREAD },
134
+ );
135
+ } catch (err) {
136
+ console.warn(`[uat] could not post to topic ${TOPIC_THREAD} (${(err as Error).message}) — skipping`);
137
+ return;
138
+ }
139
+ // Let the slow turn get underway, then fire the fast General question while
140
+ // it's still working. Use an unusual WORD token (not a number) so the slow
141
+ // turn's tool output (which prints numbers like "42Gi") can't false-match.
142
+ await new Promise((r) => setTimeout(r, 3000));
143
+ await driver.sendText(SUPERGROUP_ID, "Reply with only this exact word and nothing else: ZUCCHINI.");
144
+
145
+ const deadline = Date.now() + 130_000;
146
+ while (Date.now() < deadline && !(slowHit && fastHit)) {
147
+ const next = await Promise.race([
148
+ iter.next(),
149
+ new Promise<{ done: true; value: undefined }>((r) =>
150
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, deadline - Date.now())),
151
+ ),
152
+ ]);
153
+ if (next.done || next.value == null) break;
154
+ const m = next.value as ObservedMessage;
155
+ if (m.senderUserId === driverUserId) continue;
156
+ if (isWorkerFeedMessage(m) || isActivityFeedMessage(m)) continue;
157
+ const hit: Hit = { text: m.text, threadId: m.threadId, messageId: m.messageId, at: Date.now() };
158
+ if (slowHit == null && /CORESDONE/i.test(m.text)) slowHit = hit;
159
+ if (fastHit == null && /ZUCCHINI/i.test(m.text)) fastHit = hit;
160
+ }
161
+ void iter.return?.();
162
+
163
+ console.log(
164
+ `[multitopic-late] slow(CORESDONE)=${slowHit ? `thread=${slowHit.threadId ?? "-"} msg=${slowHit.messageId}` : "MISSING"} ` +
165
+ `fast(ZUCCHINI)=${fastHit ? `thread=${fastHit.threadId ?? "-"} msg=${fastHit.messageId}` : "MISSING"} ` +
166
+ `slowArrivedAfterFast=${slowHit && fastHit ? slowHit.at > fastHit.at : "n/a"}`,
167
+ );
168
+
169
+ expect(slowHit, "slow topic-5 answer (CORESDONE) never arrived").toBeDefined();
170
+ expect(fastHit, "fast General answer (42) never arrived").toBeDefined();
171
+ // Honest about whether the LATE-after-flip stress was actually exercised:
172
+ // it only is if the slow topic-5 answer landed AFTER the fast General one.
173
+ if (slowHit!.at <= fastHit!.at) {
174
+ console.warn(
175
+ "[multitopic-late] topic-5 answered BEFORE General — the late-after-flip case " +
176
+ "was not exercised this run (routing still asserted below).",
177
+ );
178
+ }
179
+ // The crux: the slow answer routed to topic-5, NOT bled into General — and
180
+ // this holds whether or not it arrived late (the stronger claim is when late).
181
+ expect(slowHit!.threadId).toBe(TOPIC_THREAD);
182
+ expect(fastHit!.threadId).toBeUndefined();
183
+ }, 160_000);
184
+ });