switchroom 0.16.29 → 0.16.46
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 +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -577,6 +577,97 @@ describe('projectSubagentLine', () => {
|
|
|
577
577
|
])
|
|
578
578
|
})
|
|
579
579
|
|
|
580
|
+
// ── Upstream-shape regression (Claude Code ≥2.1.x split-message writes) ──
|
|
581
|
+
// One logical assistant message is now persisted as MULTIPLE JSONL lines
|
|
582
|
+
// sharing one message.id, one content-block per line, and the terminal
|
|
583
|
+
// stop_reason (end_turn) is stamped on EVERY split line — including the
|
|
584
|
+
// leading [thinking] line that precedes the [text: final answer] line.
|
|
585
|
+
// Regression: firing the sub-agent terminal on the thinking-only line marks
|
|
586
|
+
// the sub-agent done and hands back stale/empty text BEFORE the real handback
|
|
587
|
+
// [text] line is projected. These pin the fix: the terminal must ride the
|
|
588
|
+
// content-bearing line, not the thinking-only split preamble.
|
|
589
|
+
it('does NOT emit sub_agent_turn_end on a thinking-only end_turn split line', () => {
|
|
590
|
+
// The FIRST line of a split terminal message: thinking only, but carries
|
|
591
|
+
// stop_reason end_turn (observed verbatim in 2.1.199 transcripts).
|
|
592
|
+
const st = { hasEmittedStart: true }
|
|
593
|
+
const events = projectSubagentLine(
|
|
594
|
+
JSON.stringify({
|
|
595
|
+
type: 'assistant',
|
|
596
|
+
message: {
|
|
597
|
+
id: 'msg_split',
|
|
598
|
+
stop_reason: 'end_turn',
|
|
599
|
+
content: [{ type: 'thinking', thinking: 'deciding how to summarise', signature: 'sig' }],
|
|
600
|
+
},
|
|
601
|
+
}),
|
|
602
|
+
'X',
|
|
603
|
+
st,
|
|
604
|
+
)
|
|
605
|
+
// Thinking is a projection no-op for sub-agents; crucially, NO premature
|
|
606
|
+
// terminal — the handback text has not been seen yet.
|
|
607
|
+
expect(events.some((e) => e.kind === 'sub_agent_turn_end')).toBe(false)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('emits the handback text THEN one turn_end across a split end_turn message', () => {
|
|
611
|
+
// The full split terminal message, as two consecutive JSONL lines sharing
|
|
612
|
+
// message.id — the shape that dropped background sub-agent handbacks.
|
|
613
|
+
const st = { hasEmittedStart: true }
|
|
614
|
+
const thinkingLine = JSON.stringify({
|
|
615
|
+
type: 'assistant',
|
|
616
|
+
message: {
|
|
617
|
+
id: 'msg_split2',
|
|
618
|
+
stop_reason: 'end_turn',
|
|
619
|
+
content: [{ type: 'thinking', thinking: '...', signature: 'sig' }],
|
|
620
|
+
},
|
|
621
|
+
})
|
|
622
|
+
const textLine = JSON.stringify({
|
|
623
|
+
type: 'assistant',
|
|
624
|
+
message: {
|
|
625
|
+
id: 'msg_split2',
|
|
626
|
+
stop_reason: 'end_turn',
|
|
627
|
+
content: [{ type: 'text', text: 'Handback: the fix is in session-tail.' }],
|
|
628
|
+
},
|
|
629
|
+
})
|
|
630
|
+
const all = [
|
|
631
|
+
...projectSubagentLine(thinkingLine, 'X', st),
|
|
632
|
+
...projectSubagentLine(textLine, 'X', st),
|
|
633
|
+
]
|
|
634
|
+
// The handback text must be emitted, and exactly one turn_end, and the
|
|
635
|
+
// text must come BEFORE the turn_end so the watcher captures the real
|
|
636
|
+
// result before it marks the entry done.
|
|
637
|
+
const textEvents = all.filter((e) => e.kind === 'sub_agent_text')
|
|
638
|
+
const endEvents = all.filter((e) => e.kind === 'sub_agent_turn_end')
|
|
639
|
+
expect(textEvents.length).toBe(1)
|
|
640
|
+
expect((textEvents[0] as { text: string }).text).toBe('Handback: the fix is in session-tail.')
|
|
641
|
+
expect(endEvents.length).toBe(1)
|
|
642
|
+
const textIdx = all.findIndex((e) => e.kind === 'sub_agent_text')
|
|
643
|
+
const endIdx = all.findIndex((e) => e.kind === 'sub_agent_turn_end')
|
|
644
|
+
expect(textIdx).toBeLessThan(endIdx)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('still fires terminal on the legacy single-line [thinking, text](end_turn) shape', () => {
|
|
648
|
+
// Graceful degradation: the OLD one-line-all-blocks shape has a text block
|
|
649
|
+
// on the same line, so the terminal fires exactly as before.
|
|
650
|
+
const st = { hasEmittedStart: true }
|
|
651
|
+
const events = projectSubagentLine(
|
|
652
|
+
JSON.stringify({
|
|
653
|
+
type: 'assistant',
|
|
654
|
+
message: {
|
|
655
|
+
stop_reason: 'end_turn',
|
|
656
|
+
content: [
|
|
657
|
+
{ type: 'thinking', thinking: '...', signature: 'sig' },
|
|
658
|
+
{ type: 'text', text: 'Legacy handback.' },
|
|
659
|
+
],
|
|
660
|
+
},
|
|
661
|
+
}),
|
|
662
|
+
'X',
|
|
663
|
+
st,
|
|
664
|
+
)
|
|
665
|
+
const textIdx = events.findIndex((e) => e.kind === 'sub_agent_text')
|
|
666
|
+
const endIdx = events.findIndex((e) => e.kind === 'sub_agent_turn_end')
|
|
667
|
+
expect(textIdx).toBeGreaterThanOrEqual(0)
|
|
668
|
+
expect(endIdx).toBeGreaterThan(textIdx)
|
|
669
|
+
})
|
|
670
|
+
|
|
580
671
|
it('does NOT emit sub_agent_turn_end for a tool-using assistant message (stop_reason tool_use)', () => {
|
|
581
672
|
// A mid-run assistant message that calls a tool has stop_reason 'tool_use'
|
|
582
673
|
// and keeps going — it must not be mistaken for completion.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-pin boot-recovery — the HEADLINE crash-recovery path, exercised against
|
|
3
|
+
* the REAL store + reconcile wrapper the gateway wires up (not just the pure
|
|
4
|
+
* decision fn). It reproduces the full sequence the gateway performs:
|
|
5
|
+
*
|
|
6
|
+
* reconcile a pin → persist (persist-before-pin) → simulate a crash (SKIP the
|
|
7
|
+
* clean SIGTERM sweep `unpinAllStatusPins`) → boot a "new gateway" (fresh
|
|
8
|
+
* Maps, same on-disk store) → runStatusPinBootCleanup → assert the orphan pin
|
|
9
|
+
* is unpinned AND the store is emptied.
|
|
10
|
+
*
|
|
11
|
+
* We cannot import gateway.ts directly (its module top-level runs the whole boot
|
|
12
|
+
* IIFE incl. the startup mutex + bot.start). Instead we model the gateway's
|
|
13
|
+
* status-pin subsystem faithfully with the SAME functions gateway.ts calls
|
|
14
|
+
* (reconcileAndPersistStatusPin, reconcilePin, runStatusPinBootCleanup) over an
|
|
15
|
+
* in-memory fs + a fake Telegram whose pin set we can inspect — so a regression
|
|
16
|
+
* in the ordering / cleanup contract reds here.
|
|
17
|
+
*
|
|
18
|
+
* Two variants:
|
|
19
|
+
* (A) confirmed pin → crash before the clean sweep → recovered next boot.
|
|
20
|
+
* (B) pending pin (crash INSIDE the persist-after-pin window, Fix 1) →
|
|
21
|
+
* recovered next boot.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import {
|
|
27
|
+
loadStatusPins,
|
|
28
|
+
persistStatusPins,
|
|
29
|
+
reconcileAndPersistStatusPin,
|
|
30
|
+
runStatusPinBootCleanup,
|
|
31
|
+
type PersistedStatusPin,
|
|
32
|
+
type StatusPinStoreFsSeam,
|
|
33
|
+
} from "../gateway/status-pin-store.js";
|
|
34
|
+
import { reconcilePin } from "../status-pin-driver.js";
|
|
35
|
+
import type { PinState, DesiredPin } from "../status-pin.js";
|
|
36
|
+
import { decidePinAction } from "../status-pin.js";
|
|
37
|
+
|
|
38
|
+
const PATH = "/state/agent/telegram/status-pins.json";
|
|
39
|
+
|
|
40
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
41
|
+
const files = new Map<string, string>(Object.entries(seed));
|
|
42
|
+
const fs: StatusPinStoreFsSeam = {
|
|
43
|
+
readFileSync: (p) => {
|
|
44
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`);
|
|
45
|
+
return files.get(p)!;
|
|
46
|
+
},
|
|
47
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
48
|
+
renameSync: (a, b) => {
|
|
49
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`);
|
|
50
|
+
files.set(b, files.get(a)!);
|
|
51
|
+
files.delete(a);
|
|
52
|
+
},
|
|
53
|
+
existsSync: (p) => files.has(p),
|
|
54
|
+
};
|
|
55
|
+
return { fs, files };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A fake Telegram that records the current pinned message set per chat, so we
|
|
59
|
+
* can assert an orphan is left pinned after a crash and unpinned after boot. */
|
|
60
|
+
function fakeTelegram() {
|
|
61
|
+
const pinned = new Set<string>(); // `${chatId}:${messageId}`
|
|
62
|
+
return {
|
|
63
|
+
pinned,
|
|
64
|
+
api: {
|
|
65
|
+
pinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
66
|
+
pinned.add(`${chat_id}:${message_id}`);
|
|
67
|
+
},
|
|
68
|
+
unpinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
69
|
+
pinned.delete(`${chat_id}:${message_id}`);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A faithful stand-in for the gateway's status-pin subsystem — the SAME wiring
|
|
77
|
+
* as gateway.ts's `reconcileStatusPin` / `unpinAllStatusPins` / boot cleanup,
|
|
78
|
+
* with injectable fs + api so a single test can span a "crash" and a fresh boot.
|
|
79
|
+
*/
|
|
80
|
+
function makeGateway(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegram>) {
|
|
81
|
+
const statusPinState = new Map<string, PinState>();
|
|
82
|
+
const statusPinChatIds = new Map<string, string>();
|
|
83
|
+
|
|
84
|
+
const snapshotExcept = (exceptKey: string): PersistedStatusPin[] => {
|
|
85
|
+
const out: PersistedStatusPin[] = [];
|
|
86
|
+
for (const [pinKey, st] of statusPinState) {
|
|
87
|
+
if (pinKey === exceptKey) continue;
|
|
88
|
+
const chatId = statusPinChatIds.get(pinKey);
|
|
89
|
+
if (chatId == null) continue;
|
|
90
|
+
out.push({ pinKey, chatId, messageId: st.messageId });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function reconcileStatusPin(pinKey: string, chatId: string, desired: DesiredPin) {
|
|
96
|
+
const prev = statusPinState.get(pinKey) ?? null;
|
|
97
|
+
const action = decidePinAction(prev, desired);
|
|
98
|
+
const op =
|
|
99
|
+
action.kind === "pin"
|
|
100
|
+
? ({ kind: "pin", messageId: action.messageId } as const)
|
|
101
|
+
: ({ kind: "clear" } as const);
|
|
102
|
+
const next = await reconcileAndPersistStatusPin({
|
|
103
|
+
path: PATH,
|
|
104
|
+
fs,
|
|
105
|
+
pinKey,
|
|
106
|
+
chatId,
|
|
107
|
+
op,
|
|
108
|
+
snapshotOthers: () => snapshotExcept(pinKey),
|
|
109
|
+
applyPin: () =>
|
|
110
|
+
reconcilePin({ api: tg.api, chatId, prevState: prev, desired }),
|
|
111
|
+
log: () => {},
|
|
112
|
+
});
|
|
113
|
+
if (next == null) {
|
|
114
|
+
statusPinState.delete(pinKey);
|
|
115
|
+
statusPinChatIds.delete(pinKey);
|
|
116
|
+
} else {
|
|
117
|
+
statusPinState.set(pinKey, next);
|
|
118
|
+
statusPinChatIds.set(pinKey, chatId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function bootCleanup() {
|
|
123
|
+
return runStatusPinBootCleanup({
|
|
124
|
+
path: PATH,
|
|
125
|
+
fs,
|
|
126
|
+
unpin: (chatId, messageId) => tg.api.unpinChatMessage(chatId, messageId),
|
|
127
|
+
log: () => {},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { statusPinState, reconcileStatusPin, bootCleanup };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("status-pin boot recovery (gateway wiring)", () => {
|
|
135
|
+
it("(A) confirmed pin → crash skips the clean sweep → next boot unpins the orphan + empties the store", async () => {
|
|
136
|
+
const { fs } = memFs();
|
|
137
|
+
const tg = fakeTelegram();
|
|
138
|
+
|
|
139
|
+
// ── Session 1: reconcile a pin. persist-before-pin runs; pin lands. ──
|
|
140
|
+
const gw1 = makeGateway(fs, tg);
|
|
141
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
142
|
+
|
|
143
|
+
// Pin is live in Telegram and recorded on disk (confirmed).
|
|
144
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
145
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
146
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
// ── CRASH: we deliberately DO NOT run the clean SIGTERM sweep
|
|
150
|
+
// (unpinAllStatusPins). The pin stays live; gw1's Maps vanish. ──
|
|
151
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
152
|
+
|
|
153
|
+
// ── Session 2: brand-new gateway, empty Maps, SAME on-disk store. ──
|
|
154
|
+
const gw2 = makeGateway(fs, tg);
|
|
155
|
+
const res = await gw2.bootCleanup();
|
|
156
|
+
|
|
157
|
+
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
158
|
+
expect(tg.pinned.has("-100123:715")).toBe(false); // orphan unpinned
|
|
159
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("(B) pending pin (crash inside the persist-after-pin window) → recovered next boot", async () => {
|
|
163
|
+
const { fs } = memFs();
|
|
164
|
+
const tg = fakeTelegram();
|
|
165
|
+
|
|
166
|
+
// Simulate the exact Fix-1 crash window: the pin API lands, then the
|
|
167
|
+
// process is SIGKILLed before the confirming rewrite. We model that by
|
|
168
|
+
// reconcileAndPersistStatusPin whose applyPin pins then throws.
|
|
169
|
+
await expect(
|
|
170
|
+
reconcileAndPersistStatusPin({
|
|
171
|
+
path: PATH,
|
|
172
|
+
fs,
|
|
173
|
+
pinKey: "fg:c:3",
|
|
174
|
+
chatId: "-100123",
|
|
175
|
+
op: { kind: "pin", messageId: 715 },
|
|
176
|
+
snapshotOthers: () => [],
|
|
177
|
+
applyPin: async () => {
|
|
178
|
+
await tg.api.pinChatMessage("-100123", 715); // pin lands in Telegram
|
|
179
|
+
throw new Error("SIGKILL before confirm rewrite");
|
|
180
|
+
},
|
|
181
|
+
log: () => {},
|
|
182
|
+
}),
|
|
183
|
+
).rejects.toThrow("SIGKILL");
|
|
184
|
+
|
|
185
|
+
// Orphan is live in Telegram; a PENDING record is on disk.
|
|
186
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
187
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
188
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
// Fresh boot recovers it from the pending record.
|
|
192
|
+
const gw2 = makeGateway(fs, tg);
|
|
193
|
+
const res = await gw2.bootCleanup();
|
|
194
|
+
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
195
|
+
expect(tg.pinned.has("-100123:715")).toBe(false);
|
|
196
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("clean shutdown (sweep DID run) leaves nothing for boot cleanup to do", async () => {
|
|
200
|
+
// Contrast: when the SIGTERM sweep runs (unpin each key), the store is
|
|
201
|
+
// emptied and the pin removed — boot cleanup is a no-op. This guards the
|
|
202
|
+
// ordering the other way: normal shutdown must NOT rely on boot cleanup.
|
|
203
|
+
const { fs } = memFs();
|
|
204
|
+
const tg = fakeTelegram();
|
|
205
|
+
const gw1 = makeGateway(fs, tg);
|
|
206
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
207
|
+
// Clean sweep: unpin the key.
|
|
208
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: false });
|
|
209
|
+
expect(tg.pinned.size).toBe(0);
|
|
210
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
211
|
+
|
|
212
|
+
const gw2 = makeGateway(fs, tg);
|
|
213
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Mutex-gating (P2). The boot cleanup issues real unpins against a SHARED
|
|
219
|
+
* per-agent store, so a LOSING double-boot must NOT run it — else it would strip
|
|
220
|
+
* a still-alive sibling gateway's legitimate pins. gateway.ts gates the call
|
|
221
|
+
* inside the won-lock branch (only the boot that wins acquireStartupLock calls
|
|
222
|
+
* statusPinBootCleanup; the loser process.exit(1)s first).
|
|
223
|
+
*
|
|
224
|
+
* A full held-lock integration test is impractical (gateway.ts's boot IIFE runs
|
|
225
|
+
* the mutex + bot.start at import). So we assert STRUCTURALLY that the cleanup
|
|
226
|
+
* call sits inside the won-lock branch and never at the top-level import scope,
|
|
227
|
+
* and that the loser (blocked) branch exits before it.
|
|
228
|
+
*/
|
|
229
|
+
describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
230
|
+
const gatewaySrc = readFileSync(
|
|
231
|
+
fileURLToPath(new URL("../gateway/gateway.ts", import.meta.url)),
|
|
232
|
+
"utf8",
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
it("does NOT invoke statusPinBootCleanup at module import scope", () => {
|
|
236
|
+
// The invocation must be gated on winning the mutex, never fired eagerly at
|
|
237
|
+
// import. A bare top-level `statusPinBootCleanup()` (not inside the lock
|
|
238
|
+
// block) would let a losing double-boot strip a live sibling's pins.
|
|
239
|
+
// The definition + the deliberate DO-NOT-invoke note are allowed; assert
|
|
240
|
+
// there is a note explaining the gating, and that every actual call is
|
|
241
|
+
// preceded (in source order) by the acquireStartupLock outcome check.
|
|
242
|
+
expect(gatewaySrc).toContain("acquireStartupLock");
|
|
243
|
+
// The comment documenting WHY it isn't invoked at import time.
|
|
244
|
+
expect(gatewaySrc).toMatch(
|
|
245
|
+
/NOT invoked here at import time|gated on winning the startup mutex/,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("every statusPinBootCleanup() call site follows the won-lock check", () => {
|
|
250
|
+
// Find each *invocation* (with parens), excluding the declaration.
|
|
251
|
+
const lines = gatewaySrc.split("\n");
|
|
252
|
+
const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
|
|
253
|
+
expect(lockIdx).toBeGreaterThan(-1);
|
|
254
|
+
|
|
255
|
+
const declIdx = lines.findIndex((l) =>
|
|
256
|
+
/async function statusPinBootCleanup/.test(l),
|
|
257
|
+
);
|
|
258
|
+
expect(declIdx).toBeGreaterThan(-1);
|
|
259
|
+
|
|
260
|
+
const callIdxs = lines
|
|
261
|
+
.map((l, i) => ({ l, i }))
|
|
262
|
+
.filter(
|
|
263
|
+
({ l, i }) =>
|
|
264
|
+
i !== declIdx &&
|
|
265
|
+
/statusPinBootCleanup\(\)/.test(l) &&
|
|
266
|
+
!l.trimStart().startsWith("//") &&
|
|
267
|
+
!l.includes("statusPinBootCleanup() is deliberately NOT"),
|
|
268
|
+
)
|
|
269
|
+
.map(({ i }) => i);
|
|
270
|
+
|
|
271
|
+
// There must be at least one real call, and every real call must come
|
|
272
|
+
// AFTER the acquireStartupLock outcome is available (i.e. inside the boot
|
|
273
|
+
// block that only the winner reaches).
|
|
274
|
+
expect(callIdxs.length).toBeGreaterThan(0);
|
|
275
|
+
for (const idx of callIdxs) {
|
|
276
|
+
expect(idx).toBeGreaterThan(lockIdx);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("the blocked (losing) boot exits before reaching cleanup", () => {
|
|
281
|
+
// In the mutex block, `outcome.status === 'blocked'` must lead to
|
|
282
|
+
// process.exit(1) BEFORE any statusPinBootCleanup() call in that block —
|
|
283
|
+
// so a loser never touches the shared store.
|
|
284
|
+
const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
|
|
285
|
+
expect(blockedIdx).toBeGreaterThan(-1);
|
|
286
|
+
const afterBlocked = gatewaySrc.slice(blockedIdx);
|
|
287
|
+
const exitIdx = afterBlocked.indexOf("process.exit(1)");
|
|
288
|
+
const cleanupIdx = afterBlocked.indexOf("void statusPinBootCleanup()");
|
|
289
|
+
expect(exitIdx).toBeGreaterThan(-1);
|
|
290
|
+
expect(cleanupIdx).toBeGreaterThan(-1);
|
|
291
|
+
// The exit for the blocked branch appears before the winner's cleanup call.
|
|
292
|
+
expect(exitIdx).toBeLessThan(cleanupIdx);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural test for the `bot.on('message:pinned_message')` handler that
|
|
3
|
+
* suppresses the "pinned a message" service message Telegram inserts when
|
|
4
|
+
* OUR silent status-pin fires.
|
|
5
|
+
*
|
|
6
|
+
* Why structural: like every other gateway handler, this closure is wired
|
|
7
|
+
* inline against the live `bot` instance and is not exported — a functional
|
|
8
|
+
* invocation would require booting the full grammy runtime against a mocked
|
|
9
|
+
* Bot API. The gateway suite settled on file-level grep assertions for
|
|
10
|
+
* exactly this reason (see `inbound-message-types.test.ts`). The regression
|
|
11
|
+
* we care about: a future hand dropping the handler, dropping the ownership
|
|
12
|
+
* guard (which would let us delete manual/operator pins), or swapping the
|
|
13
|
+
* house deletion wrapper for a raw `bot.api.deleteMessage` (allowlist drift).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import {
|
|
19
|
+
pinnedMessageIsOurs,
|
|
20
|
+
type TrackedStatusPin,
|
|
21
|
+
} from '../gateway/status-pin-store.js'
|
|
22
|
+
|
|
23
|
+
const SRC = readFileSync(
|
|
24
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
25
|
+
'utf8',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the body of the `bot.on('message:pinned_message', …)` handler:
|
|
30
|
+
* from the `bot.on(` line to the matching outer-scope closing brace.
|
|
31
|
+
*/
|
|
32
|
+
function pinnedMessageHandler(): string {
|
|
33
|
+
const needle = `bot.on('message:pinned_message'`
|
|
34
|
+
const start = SRC.indexOf(needle)
|
|
35
|
+
expect(start, `handler ${needle} not found`).toBeGreaterThan(0)
|
|
36
|
+
const firstBrace = SRC.indexOf('{', start)
|
|
37
|
+
let depth = 0
|
|
38
|
+
for (let i = firstBrace; i < SRC.length; i++) {
|
|
39
|
+
const c = SRC[i]
|
|
40
|
+
if (c === '{') depth++
|
|
41
|
+
else if (c === '}') {
|
|
42
|
+
depth--
|
|
43
|
+
if (depth === 0) return SRC.slice(start, i + 1)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error('could not find end of pinned_message handler')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('status-pin service-message suppression', () => {
|
|
50
|
+
it("registers a bot.on('message:pinned_message') handler", () => {
|
|
51
|
+
expect(SRC).toContain(`bot.on('message:pinned_message'`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const body = pinnedMessageHandler()
|
|
55
|
+
|
|
56
|
+
it('guards on ownership via the chat-scoped pinnedMessageIsOurs helper', () => {
|
|
57
|
+
// Must consult our tracked pins — never blindly delete a pin service
|
|
58
|
+
// message (which would nuke manual/operator pins too) — AND it must be
|
|
59
|
+
// chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
|
|
60
|
+
// messageId-only match.
|
|
61
|
+
expect(body).toContain('statusPinState')
|
|
62
|
+
expect(body).toMatch(/pinned_message\?\.message_id/)
|
|
63
|
+
expect(body).toContain('pinnedMessageIsOurs(')
|
|
64
|
+
// The chatId computed from the update must be fed to the guard.
|
|
65
|
+
expect(body).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
|
|
66
|
+
// And the tracked entries must carry their chat association.
|
|
67
|
+
expect(body).toContain('statusPinChatIds.get(pinKey)')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('bails out when the pinned message is not one of ours', () => {
|
|
71
|
+
// A `return` guard for the non-owned case must exist.
|
|
72
|
+
expect(body).toMatch(/if \(!isOurs\(\)\) return/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('deletes the service message through the robust wrapper (not a raw api call)', () => {
|
|
76
|
+
expect(body).toContain('robustApiCall(')
|
|
77
|
+
expect(body).toContain('deleteMessage')
|
|
78
|
+
// The message deleted is the service message itself, not the pinned msg.
|
|
79
|
+
expect(body).toContain('serviceMsgId')
|
|
80
|
+
// House verb tag so operators can trace it.
|
|
81
|
+
expect(body).toContain("verb: 'status-pin.delete-service-message'")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('tolerates the reconcile-store race with a short retry', () => {
|
|
85
|
+
// The service update can arrive before reconcileStatusPin stores the
|
|
86
|
+
// PinState; a single delayed re-check covers that window.
|
|
87
|
+
expect(body).toContain('setTimeout')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* BEHAVIORAL coverage of the ownership decision (BUG 1). The handler decides
|
|
93
|
+
* whether to delete a `pinned_message` service message by calling the real
|
|
94
|
+
* `pinnedMessageIsOurs(tracked, chatId, pinnedId)`. This models the exact
|
|
95
|
+
* gateway decision and asserts the delete fires ONLY for a same-chat match —
|
|
96
|
+
* the structural grep above would pass even with the messageId-only bug live.
|
|
97
|
+
*/
|
|
98
|
+
describe('service-message deletion decision (chat-scoped)', () => {
|
|
99
|
+
// Two tracked status pins in DIFFERENT chats. Chat A's pin id (715) collides
|
|
100
|
+
// with an incoming pin update in chat B.
|
|
101
|
+
const tracked: TrackedStatusPin[] = [
|
|
102
|
+
{ chatId: '-100AAA', messageId: 715 },
|
|
103
|
+
{ chatId: '-100BBB', messageId: 42 },
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
// Mirror the handler: delete iff pinnedMessageIsOurs(...) is true.
|
|
107
|
+
const wouldDelete = (chatId: string, pinnedId: number) =>
|
|
108
|
+
pinnedMessageIsOurs(tracked, chatId, pinnedId)
|
|
109
|
+
|
|
110
|
+
it('does NOT delete chat B service message when its pin id collides with chat A', () => {
|
|
111
|
+
// Chat B, pinned id 715 → belongs to chat A, not B. Must NOT delete
|
|
112
|
+
// (this is the operator-manual-pin-notice safety case).
|
|
113
|
+
expect(wouldDelete('-100BBB', 715)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('DOES delete on a genuine same-chat match', () => {
|
|
117
|
+
expect(wouldDelete('-100AAA', 715)).toBe(true)
|
|
118
|
+
expect(wouldDelete('-100BBB', 42)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does NOT delete a foreign pin id in a tracked chat', () => {
|
|
122
|
+
expect(wouldDelete('-100AAA', 9999)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* SUPERGROUP / forum-topic coverage. The handler reads only ctx.chat.id +
|
|
128
|
+
* ctx.msg.message_id and deletes by (chat_id, message_id) — topic-agnostic,
|
|
129
|
+
* with no private-chat assumption. In a forum supergroup the pinned status
|
|
130
|
+
* message carries a message_thread_id, but:
|
|
131
|
+
* - deletion needs no thread arg (chat_id + message_id is sufficient), and
|
|
132
|
+
* - message_id is unique per chat even across topics, so a single chat-scoped
|
|
133
|
+
* match is correct regardless of which topic the pin lives in.
|
|
134
|
+
* These tests model a pinned_message update whose chat is a supergroup (and a
|
|
135
|
+
* forum variant carrying message_thread_id) and assert the same chat-scoped
|
|
136
|
+
* decision holds.
|
|
137
|
+
*/
|
|
138
|
+
describe('service-message deletion in supergroups / forum topics', () => {
|
|
139
|
+
// A status pin tracked in a supergroup chat, plus a collision partner in a
|
|
140
|
+
// different supergroup.
|
|
141
|
+
const tracked: TrackedStatusPin[] = [
|
|
142
|
+
{ chatId: '-1001111', messageId: 500 }, // supergroup A
|
|
143
|
+
{ chatId: '-1002222', messageId: 500 }, // supergroup B, SAME id as A
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
// Model the handler's identity extraction from a grammy-shaped update. The
|
|
147
|
+
// thread id is present on forum updates but deliberately NOT part of the
|
|
148
|
+
// ownership/delete identity.
|
|
149
|
+
type PinnedUpdate = {
|
|
150
|
+
chat: { id: number; type: 'private' | 'supergroup' }
|
|
151
|
+
message_id: number
|
|
152
|
+
message_thread_id?: number
|
|
153
|
+
pinned_message: { message_id: number }
|
|
154
|
+
}
|
|
155
|
+
const wouldDelete = (u: PinnedUpdate) =>
|
|
156
|
+
pinnedMessageIsOurs(tracked, String(u.chat.id), u.pinned_message.message_id)
|
|
157
|
+
|
|
158
|
+
it('deletes on a genuine same-chat match in a plain supergroup', () => {
|
|
159
|
+
const update: PinnedUpdate = {
|
|
160
|
+
chat: { id: -1001111, type: 'supergroup' },
|
|
161
|
+
message_id: 9001, // the service message
|
|
162
|
+
pinned_message: { message_id: 500 },
|
|
163
|
+
}
|
|
164
|
+
expect(wouldDelete(update)).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('deletes on a same-chat match in a FORUM topic (message_thread_id set)', () => {
|
|
168
|
+
const update: PinnedUpdate = {
|
|
169
|
+
chat: { id: -1001111, type: 'supergroup' },
|
|
170
|
+
message_id: 9002,
|
|
171
|
+
message_thread_id: 77, // forum topic — irrelevant to the delete identity
|
|
172
|
+
pinned_message: { message_id: 500 },
|
|
173
|
+
}
|
|
174
|
+
expect(wouldDelete(update)).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does NOT delete when the id collides across two supergroups', () => {
|
|
178
|
+
// Supergroup B receives a pin update for id 500, which also happens to be
|
|
179
|
+
// supergroup A's tracked status-pin id. Chat-scoped guard keeps them apart.
|
|
180
|
+
const update: PinnedUpdate = {
|
|
181
|
+
chat: { id: -1002222, type: 'supergroup' },
|
|
182
|
+
message_id: 9003,
|
|
183
|
+
message_thread_id: 12,
|
|
184
|
+
pinned_message: { message_id: 500 },
|
|
185
|
+
}
|
|
186
|
+
// 500 IS tracked for chat -1002222 too in this fixture, so this is a
|
|
187
|
+
// genuine match — assert the positive, then flip to a true cross-chat miss.
|
|
188
|
+
expect(wouldDelete(update)).toBe(true)
|
|
189
|
+
|
|
190
|
+
// A supergroup NOT in the tracked set, colliding on id 500 → must NOT fire.
|
|
191
|
+
const foreign: PinnedUpdate = {
|
|
192
|
+
chat: { id: -1009999, type: 'supergroup' },
|
|
193
|
+
message_id: 9004,
|
|
194
|
+
message_thread_id: 5,
|
|
195
|
+
pinned_message: { message_id: 500 },
|
|
196
|
+
}
|
|
197
|
+
expect(wouldDelete(foreign)).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('the handler logs a missing-admin-right hint on delete failure (supergroup)', () => {
|
|
201
|
+
// Structural: the catch block names the likely supergroup cause so an
|
|
202
|
+
// operator isn't left with silent nothing.
|
|
203
|
+
const src = readFileSync(
|
|
204
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
205
|
+
'utf8',
|
|
206
|
+
)
|
|
207
|
+
expect(src).toContain('could not delete pin service message')
|
|
208
|
+
expect(src).toContain('missing can_delete_messages')
|
|
209
|
+
})
|
|
210
|
+
})
|