switchroom 0.20.11 → 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.
Files changed (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -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
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * pin_message tool retirement (#4452).
3
+ *
4
+ * The agent-facing `pin_message` MCP tool was removed: agents may no longer
5
+ * hand-pin arbitrary messages. The framework's OWN auto-pin
6
+ * (`pin_status_while_working` — the status/activity card and the 🛠 Worker
7
+ * card) is unaffected; that is the one sanctioned pin and is exercised by
8
+ * status-pin-lifecycle.test.ts.
9
+ *
10
+ * These are OUTCOME assertions on the actual offered surface — they fail if the
11
+ * tool is ever re-registered in the bridge schema, re-wired into the gateway
12
+ * dispatch, or re-granted in the agent scaffold. bridge.ts and gateway.ts each
13
+ * run boot side-effects at import (a top-level `await main()` / the gateway boot
14
+ * IIFE), so they cannot be imported here; we assert against their source, which
15
+ * IS the registration surface.
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import { readFileSync } from 'node:fs'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const here = fileURLToPath(new URL('.', import.meta.url))
22
+ const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), 'utf8')
23
+
24
+ describe('pin_message MCP tool is retired (#4452)', () => {
25
+ it('is NOT registered as a bridge tool schema', () => {
26
+ const bridge = read('../bridge/bridge.ts')
27
+ // The tool-schema registration form is `name: 'pin_message'`. Its absence
28
+ // means ListTools no longer offers it to the agent.
29
+ expect(bridge).not.toMatch(/name:\s*['"]pin_message['"]/)
30
+ })
31
+
32
+ it('is NOT in the gateway IPC tool allowlist and has no dispatch case', () => {
33
+ const gateway = read('../gateway/gateway.ts')
34
+ // ALLOWED_TOOLS gate: a bridge could not invoke it even by name.
35
+ expect(gateway).not.toMatch(/['"]pin_message['"]/)
36
+ // No dispatch arm and no handler.
37
+ expect(gateway).not.toMatch(/case\s+['"]pin_message['"]/)
38
+ expect(gateway).not.toContain('executePinMessage')
39
+ })
40
+
41
+ it('is NOT granted in the agent scaffold permission surface', () => {
42
+ const scaffold = read('../../src/agents/scaffold.ts')
43
+ expect(scaffold).not.toContain('mcp__switchroom-telegram__pin_message')
44
+ })
45
+
46
+ // Sanity: prove the assertions above are meaningful by confirming a tool that
47
+ // SURVIVED is still present in each surface (a test that can't fail is not a
48
+ // test — this pins the read paths to real content).
49
+ it('a surviving tool (delete_message) is still registered — guards false-green', () => {
50
+ expect(read('../bridge/bridge.ts')).toMatch(/name:\s*['"]delete_message['"]/)
51
+ expect(read('../gateway/gateway.ts')).toContain('executeDeleteMessage')
52
+ expect(read('../../src/agents/scaffold.ts')).toContain(
53
+ 'mcp__switchroom-telegram__delete_message',
54
+ )
55
+ })
56
+
57
+ it('the framework auto status-pin machinery is untouched', () => {
58
+ // Change 1 must not disturb pin_status_while_working (the ONE sanctioned pin).
59
+ const gateway = read('../gateway/gateway.ts')
60
+ expect(gateway).toContain('PIN_STATUS_WHILE_WORKING')
61
+ expect(gateway).toContain('runStatusPinBootCleanup')
62
+ void here
63
+ })
64
+ })
@@ -259,6 +259,44 @@ describe("status-pin boot recovery (gateway wiring)", () => {
259
259
  expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
260
260
  });
261
261
 
262
+ it("(SAFETY) boot cleanup NEVER unpins a message the framework did not record (a user's manual pin survives)", async () => {
263
+ // The hard safety constraint of the boot reap: it reaps ONLY the framework's
264
+ // own pins (the status/activity card, the 🛠 Worker card — the rows this
265
+ // process itself wrote to status-pins.json). A message the USER deliberately
266
+ // pinned is never recorded there, so the reap must leave it completely
267
+ // untouched. A boot that nuked a user's real pin is a regression worse than a
268
+ // stranded orphan. This test makes that structural guarantee an assertion:
269
+ // the store never sees the user's id, so the reap can never target it.
270
+ const { fs } = memFs();
271
+ const tg = fakeTelegram();
272
+
273
+ // Session 1: the framework pins its OWN work-scoped status card (recorded).
274
+ const gw1 = makeGateway(fs, tg);
275
+ await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
276
+
277
+ // A user manually pins their own message in the SAME chat. It lives in the
278
+ // Telegram pin stack but is NOT in status-pins.json — the framework has no
279
+ // record of it and no business touching it.
280
+ tg.pinned.add("-100123:999");
281
+
282
+ // The framework's pin is the ONLY recorded row; the user's is not present.
283
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
284
+ { pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
285
+ ]);
286
+ expect(loadStatusPins(PATH, fs).some((r) => r.messageId === 999)).toBe(false);
287
+
288
+ // ── CRASH, then a fresh boot runs the reap. ──
289
+ const gw2 = makeGateway(fs, tg);
290
+ const res = await gw2.bootCleanup();
291
+
292
+ // Exactly the framework's OWN pin was cleared …
293
+ expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
294
+ expect(tg.pinned.has("-100123:715")).toBe(false);
295
+ // … and the user's manual pin is untouched — never unpinned.
296
+ expect(tg.pinned.has("-100123:999")).toBe(true);
297
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
298
+ });
299
+
262
300
  it("clean shutdown (sweep DID run) leaves nothing for boot cleanup to do", async () => {
263
301
  // Contrast: when the SIGTERM sweep runs (unpin each key), the store is
264
302
  // emptied and the pin removed — boot cleanup is a no-op. This guards the
@@ -304,6 +304,45 @@ describe('createWorkerActivityFeed', () => {
304
304
  expect(feed.has('w1')).toBe(true)
305
305
  })
306
306
 
307
+ it('defaults firstPaintMin to 4000ms (no explicit firstPaintMinMs)', async () => {
308
+ // Guards the module default. A prose-silent worker's first card must paint
309
+ // once it has run ≥4000ms and NOT before — asserting the actual default so
310
+ // this fails on the old 8000. #fix/worker-feed-first-paint.
311
+ const bot = makeFakeBot()
312
+ let clock = 0
313
+ const feed = createWorkerActivityFeed({ bot, now: () => clock })
314
+ // Just below the 4000 default: still held.
315
+ clock = 3999
316
+ await feed.update('w1', 'chat', view({ elapsedMs: 3999 }))
317
+ expect(bot.sent).toHaveLength(0)
318
+ expect(feed.has('w1')).toBe(false)
319
+ // At/above 4000: paints. (On the old 8000 default this would still be held
320
+ // and bot.sent would be empty — so this assertion pins the new value.)
321
+ clock = 4000
322
+ await feed.update('w1', 'chat', view({ elapsedMs: 4000 }))
323
+ expect(bot.sent).toHaveLength(1)
324
+ expect(feed.has('w1')).toBe(true)
325
+ })
326
+
327
+ it('honors an explicit firstPaintMinMs override (env plumbing shape)', async () => {
328
+ // The gateway threads SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS through as
329
+ // firstPaintMinMs; an operator override (e.g. reverting to 8000) must hold
330
+ // first paint until that value, overriding the 4000 default.
331
+ const bot = makeFakeBot()
332
+ let clock = 0
333
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
334
+ // Past the 4000 default but below the override: still held.
335
+ clock = 5000
336
+ await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
337
+ expect(bot.sent).toHaveLength(0)
338
+ expect(feed.has('w1')).toBe(false)
339
+ // At the override threshold: paints.
340
+ clock = 8000
341
+ await feed.update('w1', 'chat', view({ elapsedMs: 8000 }))
342
+ expect(bot.sent).toHaveLength(1)
343
+ expect(feed.has('w1')).toBe(true)
344
+ })
345
+
307
346
  it('messageIdOf exposes the posted message id (for status-pin) and is null before paint / after finish', async () => {
308
347
  const bot = makeFakeBot()
309
348
  let clock = 0
@@ -706,7 +745,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
706
745
  setInterval: () => 1,
707
746
  clearInterval: () => {},
708
747
  })
709
- // First paint at elapsed 0 (firstPaintMin default 8000 — use 9000). The
748
+ // First paint at elapsed 0 (firstPaintMin default 4000 — use 9000). The
710
749
  // narrative line 'pulling data' lands here, so the current step starts now.
711
750
  clock = 19_000
712
751
  await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
@@ -800,7 +800,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
800
800
  const floodWaitRemainingMs = opts.floodWaitRemainingMs ?? (() => 0)
801
801
  const minEditInterval = opts.minEditIntervalMs ?? 2500
802
802
  const elapsedRefreshMs = Math.max(minEditInterval, Math.floor(opts.elapsedRefreshMs ?? 15000))
803
- const firstPaintMin = opts.firstPaintMinMs ?? 8000
803
+ const firstPaintMin = opts.firstPaintMinMs ?? 4000
804
804
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
805
805
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
806
806
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))