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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +32 -25
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +3069 -1146
- package/dist/host-control/main.js +33 -26
- package/dist/vault/approvals/kernel-server.js +32 -25
- package/dist/vault/broker/server.js +32 -25
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +7 -4
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +219 -127
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +25 -59
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +8 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- 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
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- 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
|
|
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 ??
|
|
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))
|