switchroom 0.20.12 → 0.20.13

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,401 @@
1
+ /**
2
+ * outbox-self-improve-review.test.ts — the self-improvement review labelling
3
+ * rule, end to end through the REAL Stop hook and the REAL outbox sweep (Ken,
4
+ * 2026-08-07).
5
+ *
6
+ * THE LEAK THIS CLOSES
7
+ * --------------------
8
+ * A self-improvement review turn is a SYNTHESIZED inbound
9
+ * (`source="self_improve_review"`) injected off the operator reply path. Its
10
+ * trailing transcript prose is the agent's own reasoning. The outbox backstop
11
+ * captured that prose and the sweep delivered it into the operator's DM as a
12
+ * RAW, UNLABELLED message — the bug.
13
+ *
14
+ * THE RULE UNDER TEST
15
+ * -------------------
16
+ * A review-originated backstop record is delivered to the operator ONLY IF its
17
+ * text is a well-formed self-improvement CARD (opens with the title line).
18
+ * Everything else — the raw reasoning — is suppressed as `internal`.
19
+ *
20
+ * (a) SURFACING: a review turn whose final text is a card ⇒ the card is
21
+ * delivered, self-labelled, journaled as a real delivery.
22
+ * (b) NO-OP: a review turn whose final text is raw reasoning ⇒ suppressed,
23
+ * zero chat sends, journaled as an internal suppression.
24
+ * (c) NORMAL: a non-review turn is delivered byte-for-byte unchanged.
25
+ *
26
+ * Every assertion drives REAL machinery: the REAL Stop hook spawned as a
27
+ * subprocess against a REAL transcript, and the REAL `sweepOutbox` with the
28
+ * REAL `createOutboxSend` adapter against a RECORDING fake Bot API.
29
+ */
30
+
31
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
32
+ import { spawnSync } from "node:child_process";
33
+ import {
34
+ mkdtempSync,
35
+ mkdirSync,
36
+ writeFileSync,
37
+ readdirSync,
38
+ readFileSync,
39
+ existsSync,
40
+ rmSync,
41
+ } from "node:fs";
42
+ import { tmpdir } from "node:os";
43
+ import { join, resolve } from "node:path";
44
+
45
+ import { sweepOutbox, createOutboxSend } from "../gateway/outbox-sweep.js";
46
+ import { sha256Hex } from "../outbox.js";
47
+ import { SELF_IMPROVEMENT_TITLE } from "../hooks/audience-classify.mjs";
48
+
49
+ const HOOK = resolve(__dirname, "..", "hooks", "silent-end-interrupt-stop.mjs");
50
+
51
+ // Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
52
+ const DM_CHAT = "5550001";
53
+ const INBOUND_MSG_ID = 8420;
54
+
55
+ /** Raw review reasoning — the text that must never reach the operator. */
56
+ const REVIEW_REASONING = [
57
+ "I own personal-garmin, but the script I hand-rolled against this turn was the",
58
+ "shared garmin skill's garmin-history-pull, which only samples every 3rd day.",
59
+ "The durable fix is a first-class hrv-trend subcommand; that is a T2 change so",
60
+ "I logged it as a pending suggestion rather than auto-applying it here.",
61
+ ].join(" ");
62
+
63
+ /** A well-formed card, exactly as `buildReviewPrompt` instructs the model. */
64
+ const REVIEW_CARD = [
65
+ `${SELF_IMPROVEMENT_TITLE} — pending suggestion logged`,
66
+ "- **Signal:** had to hand-roll python twice to pull daily HRV",
67
+ "- **Suggestion:** add an `hrv-trend` command to the garmin skill",
68
+ "- **Status:** T2, logged for your review, nothing auto-applied",
69
+ ].join("\n");
70
+
71
+ /** A normal operator answer on a normal (non-review) turn. No markdown-special
72
+ * characters, so the rich-send path delivers it byte-for-byte (letting the
73
+ * "unchanged" assertion be exact equality rather than a substring). */
74
+ const NORMAL_ANSWER = "Your HRV trended up this week, sitting around thirty against last week.";
75
+
76
+ function makeStateDir(): string {
77
+ // NEVER ~/.switchroom — a test that writes there corrupts production state.
78
+ return mkdtempSync(join(tmpdir(), "self-improve-review-"));
79
+ }
80
+
81
+ function writeTranscript(dir: string, lines: object[]): string {
82
+ const p = join(dir, "transcript.jsonl");
83
+ writeFileSync(p, lines.map((l) => JSON.stringify(l)).join("\n"), "utf8");
84
+ return p;
85
+ }
86
+
87
+ function runHook(transcriptPath: string, stateDir: string, extraEnv: Record<string, string> = {}) {
88
+ return spawnSync("node", [HOOK], {
89
+ input: JSON.stringify({ session_id: "s", transcript_path: transcriptPath }),
90
+ encoding: "utf8",
91
+ timeout: 10_000,
92
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
93
+ });
94
+ }
95
+
96
+ interface RecordShape {
97
+ turnNonce: string;
98
+ text: string;
99
+ audience?: string;
100
+ reviewOriginated?: unknown;
101
+ chatId: string | null;
102
+ source: string;
103
+ }
104
+
105
+ function outboxRecords(dir: string): RecordShape[] {
106
+ const outbox = join(dir, "outbox");
107
+ if (!existsSync(outbox)) return [];
108
+ return readdirSync(outbox)
109
+ .filter((f) => f.endsWith(".json") && f !== "delivered.jsonl")
110
+ .map((f) => JSON.parse(readFileSync(join(outbox, f), "utf8")) as RecordShape);
111
+ }
112
+
113
+ function journalLines(dir: string): Array<Record<string, unknown>> {
114
+ const p = join(dir, "outbox", "delivered.jsonl");
115
+ if (!existsSync(p)) return [];
116
+ return readFileSync(p, "utf8")
117
+ .split("\n")
118
+ .filter((l) => l.trim().length > 0)
119
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
120
+ }
121
+
122
+ /** A RECORDING fake Bot API — every chat-visible method is counted. */
123
+ function recordingBot() {
124
+ const calls: Array<{ method: string; chatId: string; text: string }> = [];
125
+ let nextId = 900;
126
+ return {
127
+ chatCalls: () => calls,
128
+ api: {
129
+ sendRichMessage: async (chatId: string, body: { markdown: string }) => {
130
+ calls.push({ method: "sendRichMessage", chatId, text: body.markdown });
131
+ return { message_id: nextId++ };
132
+ },
133
+ sendMessage: async (chatId: string, text: string) => {
134
+ calls.push({ method: "sendMessage", chatId, text });
135
+ return { message_id: nextId++ };
136
+ },
137
+ editMessageText: async (chatId: string, _mid: number, text: string) => {
138
+ calls.push({ method: "editMessageText", chatId, text });
139
+ return { message_id: nextId++ };
140
+ },
141
+ },
142
+ };
143
+ }
144
+
145
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn();
146
+
147
+ /** Drive ONE real sweep tick against the recording bot. */
148
+ async function realSweep(
149
+ stateDir: string,
150
+ bot: ReturnType<typeof recordingBot>,
151
+ opts: { audienceGateEnabled?: boolean } = {},
152
+ ) {
153
+ const framingLines: string[] = [];
154
+ const escalations: string[] = [];
155
+ const summary = await sweepOutbox({
156
+ send: createOutboxSend({ getBot: () => bot, retry: passthroughRetry }),
157
+ textAlreadyDelivered: () => false,
158
+ stateDir,
159
+ now: () => Date.now() + 60_000,
160
+ quietMs: 0,
161
+ log: () => {},
162
+ ...(opts.audienceGateEnabled === undefined
163
+ ? {}
164
+ : { audienceGateEnabled: () => opts.audienceGateEnabled! }),
165
+ logSelfImprovementFraming: (l) => framingLines.push(l),
166
+ escalateInternalSuppression: (l) => escalations.push(l),
167
+ });
168
+ return { summary, framingLines, escalations };
169
+ }
170
+
171
+ /**
172
+ * The REAL review-turn shape: the synthesized review inbound (channel-wrapped,
173
+ * carrying `source="self_improve_review"` and the real operator chat the gateway
174
+ * fell back to), then the agent's trailing final text.
175
+ */
176
+ function reviewTranscript(dir: string, trailing: string): string {
177
+ return writeTranscript(dir, [
178
+ {
179
+ type: "queue-operation",
180
+ operation: "enqueue",
181
+ content:
182
+ `<channel source="self_improve_review" chat_id="${DM_CHAT}" ` +
183
+ `message_id="${INBOUND_MSG_ID}">[self-improvement review] The turn-end gate ` +
184
+ `detected a learning signal. Run a focused, forked review.</channel>`,
185
+ timestamp: 1000,
186
+ },
187
+ { type: "assistant", message: { content: [{ type: "text", text: trailing }] } },
188
+ ]);
189
+ }
190
+
191
+ /**
192
+ * #4489 shape: the review turn's OWN reply tool call throws (a
193
+ * `disable_notification: true` interim ack, so it never qualifies as the
194
+ * final answer — mirrors `foregroundThrowTranscript` in
195
+ * outbox-provenance-4141.test.ts), and the trailing text is a well-formed
196
+ * card. This is the ONLY shape that can carry both `replyToolThrewThisTurn`
197
+ * and a card body on the same record, which is what #4489's duplicated-title
198
+ * bug required.
199
+ */
200
+ function reviewThrowTranscript(dir: string, trailing: string): string {
201
+ return writeTranscript(dir, [
202
+ {
203
+ type: "queue-operation",
204
+ operation: "enqueue",
205
+ content:
206
+ `<channel source="self_improve_review" chat_id="${DM_CHAT}" ` +
207
+ `message_id="${INBOUND_MSG_ID}">[self-improvement review] The turn-end gate ` +
208
+ `detected a learning signal. Run a focused, forked review.</channel>`,
209
+ timestamp: 1000,
210
+ },
211
+ {
212
+ type: "assistant",
213
+ message: {
214
+ content: [
215
+ {
216
+ type: "tool_use",
217
+ id: "toolu_review_ack",
218
+ name: "mcp__switchroom-telegram__reply",
219
+ input: { text: "Reviewing...", disable_notification: true },
220
+ },
221
+ ],
222
+ },
223
+ },
224
+ {
225
+ type: "user",
226
+ message: {
227
+ content: [
228
+ {
229
+ type: "tool_result",
230
+ tool_use_id: "toolu_review_ack",
231
+ is_error: true,
232
+ content: "Error: FLOOD_WAIT_ACTIVE — send rejected",
233
+ },
234
+ ],
235
+ },
236
+ },
237
+ { type: "assistant", message: { content: [{ type: "text", text: trailing }] } },
238
+ ]);
239
+ }
240
+
241
+ describe("self-improvement review — the card gate, end to end", () => {
242
+ let dir: string;
243
+ beforeEach(() => {
244
+ dir = makeStateDir();
245
+ });
246
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
247
+
248
+ it("(a) SURFACING: a review turn ending in a CARD delivers it, self-labelled", async () => {
249
+ const t = reviewTranscript(dir, REVIEW_CARD);
250
+ expect(runHook(t, dir).status).toBe(0);
251
+
252
+ const records = outboxRecords(dir);
253
+ expect(records).toHaveLength(1);
254
+ // Detected as review-originated, and the card routes to the operator.
255
+ expect(records[0].reviewOriginated).toBe(true);
256
+ expect(records[0].audience).toBe("user");
257
+
258
+ const bot = recordingBot();
259
+ const run = await realSweep(dir, bot);
260
+
261
+ // Delivered exactly once, carrying the card verbatim (title first).
262
+ expect(bot.chatCalls()).toHaveLength(1);
263
+ expect(run.summary.delivered).toBe(1);
264
+ expect(run.summary.audienceSuppressed).toBeUndefined();
265
+ const sent = bot.chatCalls()[0].text;
266
+ expect(sent).toContain(SELF_IMPROVEMENT_TITLE);
267
+ expect(sent).toContain("add an `hrv-trend` command");
268
+ expect(sent.startsWith(SELF_IMPROVEMENT_TITLE)).toBe(true);
269
+ expect(bot.chatCalls()[0].chatId).toBe(DM_CHAT);
270
+
271
+ // Journaled as a real delivery (carries a message id, not a suppression).
272
+ const journal = journalLines(dir);
273
+ expect(journal).toHaveLength(1);
274
+ expect(journal[0].tgMessageId).toBeDefined();
275
+ expect(journal[0].suppressedAudience).toBeUndefined();
276
+ });
277
+
278
+ it("(b) NO-OP: a review turn ending in RAW REASONING is suppressed — zero sends", async () => {
279
+ const t = reviewTranscript(dir, REVIEW_REASONING);
280
+ expect(runHook(t, dir).status).toBe(0);
281
+
282
+ const records = outboxRecords(dir);
283
+ expect(records).toHaveLength(1);
284
+ expect(records[0].reviewOriginated).toBe(true);
285
+ // The leak text classifies internal by construction.
286
+ expect(records[0].audience).toBe("internal");
287
+
288
+ const bot = recordingBot();
289
+ const run = await realSweep(dir, bot);
290
+
291
+ // The bug, closed: nothing reaches the operator.
292
+ expect(bot.chatCalls()).toEqual([]);
293
+ expect(run.summary.delivered ?? 0).toBe(0);
294
+ expect(run.summary.audienceSuppressed).toBe(1);
295
+ expect(journalLines(dir)[0].suppressedAudience).toBe("internal");
296
+ });
297
+
298
+ it("(b') REVERT CHECK: with the audience gate OFF the leak reproduces — but TITLED", async () => {
299
+ // Same raw reasoning; only the gate differs. This proves (b) is load-bearing
300
+ // AND that the residual framing labels the gate-off delivery so it is never
301
+ // raw/unlabelled — the task's minimum guarantee.
302
+ const t = reviewTranscript(dir, REVIEW_REASONING);
303
+ expect(runHook(t, dir).status).toBe(0);
304
+
305
+ const bot = recordingBot();
306
+ const run = await realSweep(dir, bot, { audienceGateEnabled: false });
307
+
308
+ expect(bot.chatCalls()).toHaveLength(1);
309
+ const sent = bot.chatCalls()[0].text;
310
+ // The raw reasoning is present (the pre-change leak) BUT now titled.
311
+ expect(sent).toContain(REVIEW_REASONING);
312
+ expect(sent.startsWith(SELF_IMPROVEMENT_TITLE)).toBe(true);
313
+ expect(run.summary.selfImprovementFramed).toBe(1);
314
+ expect(run.framingLines).toHaveLength(1);
315
+ expect(journalLines(dir)[0].framedSelfImprovement).toBe("self-improve");
316
+ });
317
+
318
+ it("(c) NORMAL: a non-review record is delivered byte-for-byte, no review handling", async () => {
319
+ // A normal foreground turn is delivered LIVE by the turn-flush path and
320
+ // writes no outbox record at all (verified: the real hook elects
321
+ // `flush-will-deliver` for a `source="telegram"` turn). The invariant this
322
+ // case guards is the sweep side: a non-review outbox record — the shape the
323
+ // sweep DOES handle, e.g. a background handback or a flood-queued reply — is
324
+ // untouched by any of the self-improvement machinery. Constructed directly
325
+ // on disk (mirrors the LEGACY pattern in outbox-provenance-4141.test.ts) so
326
+ // "byte-for-byte unchanged" can be asserted as exact equality.
327
+ const outbox = join(dir, "outbox");
328
+ mkdirSync(outbox, { recursive: true });
329
+ const nonce = `${DM_CHAT}:_#7788`;
330
+ const normal = {
331
+ turnNonce: nonce,
332
+ chatId: DM_CHAT,
333
+ threadId: null,
334
+ text: NORMAL_ANSWER,
335
+ textSha256: sha256Hex(NORMAL_ANSWER),
336
+ // Inside the max-age window ⇒ no delivery prefix, so exact equality holds.
337
+ createdAt: Date.now(),
338
+ source: "task-notification",
339
+ audience: "user",
340
+ };
341
+ // The review fields simply do not exist on a normal record.
342
+ expect(Object.keys(normal)).not.toContain("reviewOriginated");
343
+ writeFileSync(join(outbox, `${nonce}.json`), JSON.stringify(normal), "utf8");
344
+
345
+ const bot = recordingBot();
346
+ const run = await realSweep(dir, bot);
347
+
348
+ // Delivered, verbatim, with no self-improvement title anywhere.
349
+ expect(bot.chatCalls()).toHaveLength(1);
350
+ expect(run.summary.delivered).toBe(1);
351
+ expect(bot.chatCalls()[0].text).toBe(NORMAL_ANSWER);
352
+ expect(bot.chatCalls()[0].text).not.toContain(SELF_IMPROVEMENT_TITLE);
353
+ expect(run.summary.selfImprovementFramed).toBeUndefined();
354
+ expect(run.summary.audienceSuppressed).toBeUndefined();
355
+ // Journal parity: a plain delivery, keyed on the raw text, unframed.
356
+ expect(journalLines(dir)[0].textSha256).toBe(sha256Hex(NORMAL_ANSWER));
357
+ expect(journalLines(dir)[0].framedSelfImprovement).toBeUndefined();
358
+ });
359
+
360
+ // ───────────────────────────────────────────────────────────────────────
361
+ // #4489 — a review record that is BOTH a card AND `replyToolThrewThisTurn`
362
+ // must never acquire a second, duplicated title. `decideOutboxSweep`
363
+ // applies the reply-throw provenance banner BEFORE the self-improvement
364
+ // title, so by the time the self-improvement framing decision ran on the
365
+ // COMPOSED body (banner + card), `applySelfImprovementFraming`'s own
366
+ // idempotency check — which only inspects what it was handed — could no
367
+ // longer see that the underlying text already opened with the title. Fixed
368
+ // by gating the framing DECISION on the raw `record.text`, not the
369
+ // provenance-composed body.
370
+ // ───────────────────────────────────────────────────────────────────────
371
+ it("#4489: a review record that is both a CARD and reply-throw gets exactly one title", async () => {
372
+ const t = reviewThrowTranscript(dir, REVIEW_CARD);
373
+ expect(runHook(t, dir).status).toBe(0);
374
+
375
+ const records = outboxRecords(dir);
376
+ expect(records).toHaveLength(1);
377
+ expect(records[0].reviewOriginated).toBe(true);
378
+ expect(records[0].replyToolThrewThisTurn).toBe(true);
379
+ // The card routes `user` regardless of the throw (card gate wins).
380
+ expect(records[0].audience).toBe("user");
381
+
382
+ const bot = recordingBot();
383
+ // Audience gate OFF per the issue's repro shape — makes the self-improve
384
+ // framing layer the one under test here, isolated from the card gate
385
+ // (which already suppresses the leak for a non-card body; see (b)/(b')
386
+ // above). A card's audience is `user` regardless of the gate, so this
387
+ // does not change which branch delivers it — only removes a confound.
388
+ const run = await realSweep(dir, bot, { audienceGateEnabled: false });
389
+
390
+ expect(bot.chatCalls()).toHaveLength(1);
391
+ const sent = bot.chatCalls()[0].text;
392
+ // REGRESSION GUARD: exactly one occurrence of the title, not two. Before
393
+ // the fix this was 2 — the reply-throw banner is prepended in front of
394
+ // the card, so the composed body no longer opens with the title even
395
+ // though the raw card does, and the framing decision (running on the
396
+ // composed body) could not tell.
397
+ const titleOccurrences = sent.split(SELF_IMPROVEMENT_TITLE).length - 1;
398
+ expect(titleOccurrences).toBe(1);
399
+ expect(sent).toContain("add an `hrv-trend` command");
400
+ });
401
+ });