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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +5 -1
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +1472 -89
- package/dist/host-control/main.js +6 -2
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +5 -1
- package/package.json +7 -4
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/dist/gateway/gateway.js +169 -68
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +1 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stream-render.ts +4 -0
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +8 -1
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
- package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
- package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
|
@@ -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
|
+
});
|