switchroom 0.18.11 → 0.18.12

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 (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -46,6 +46,24 @@ function onPermissionRequestBody(): string {
46
46
  return rest.slice(0, end)
47
47
  }
48
48
 
49
+ /**
50
+ * Slice the body of `postPermissionCard` — THE card emitter.
51
+ *
52
+ * The send used to be inlined in `onPermissionRequest`. The #3084 follow-up
53
+ * extracted it here so it has two callers (the initial delivery, and the
54
+ * reaper's re-delivery of a card held through a flood ban). The routing
55
+ * contract these tests pin is unchanged; it just lives one function over, and
56
+ * now BOTH delivery paths inherit it — which is the point of the extraction.
57
+ */
58
+ function postPermissionCardBody(): string {
59
+ const start = GATEWAY_SRC.indexOf('function postPermissionCard(')
60
+ expect(start).toBeGreaterThan(-1)
61
+ const rest = GATEWAY_SRC.slice(start)
62
+ const end = rest.indexOf('\nfunction ', 1)
63
+ expect(end).toBeGreaterThan(-1)
64
+ return rest.slice(0, end)
65
+ }
66
+
49
67
  describe('permission card routing', () => {
50
68
  it('the shared target helper exists', () => {
51
69
  expect(
@@ -53,19 +71,26 @@ describe('permission card routing', () => {
53
71
  ).toBe(true)
54
72
  })
55
73
 
56
- it('the initial card emitter routes via resolvePermissionCardTargets()', () => {
57
- expect(onPermissionRequestBody()).toContain('resolvePermissionCardTargets()')
74
+ it('the card emitter routes via resolvePermissionCardTargets()', () => {
75
+ expect(postPermissionCardBody()).toContain('resolvePermissionCardTargets()')
58
76
  })
59
77
 
60
- it('the initial card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
78
+ it('the card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
61
79
  // The raw fan-out loop is what sent supergroup cards to operator DMs.
62
- expect(onPermissionRequestBody()).not.toMatch(
80
+ expect(postPermissionCardBody()).not.toMatch(
63
81
  /for\s*\(\s*const\s+chat_id\s+of\s+access\.allowFrom\s*\)/,
64
82
  )
65
83
  })
66
84
 
67
85
  it('the card send is wrapped in retryWithThreadFallback (stale-topic → thread-less, not a silent drop)', () => {
68
- expect(onPermissionRequestBody()).toContain('retryWithThreadFallback')
86
+ expect(postPermissionCardBody()).toContain('retryWithThreadFallback')
87
+ })
88
+
89
+ it('the initial permission request still delivers through that one emitter', () => {
90
+ // Guards the extraction itself: onPermissionRequest must not regrow its own
91
+ // send path and drift from the re-delivery path.
92
+ expect(onPermissionRequestBody()).toContain('postPermissionCard(requestId, pendEntry)')
93
+ expect(onPermissionRequestBody()).not.toContain('retryWithThreadFallback')
69
94
  })
70
95
 
71
96
  it('the resume message uses the SAME helper, so card + resume cannot drift', () => {
@@ -45,15 +45,15 @@ describe('no-repeat-on-timeout wiring', () => {
45
45
  // when Bug 2 added per-tool TTL + the timed-out keyboard-strip to this
46
46
  // block — the signature-record line now sits further down but the wiring
47
47
  // is intact.
48
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
48
+ const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
49
49
  expect(sweep).toContain('timeoutDenyMessage(')
50
50
  expect(sweep).toContain('permissionTimeoutSignatures.set(')
51
51
  })
52
52
 
53
53
  it('the TTL auto-deny strips the timed-out card keyboard (Bug 2)', () => {
54
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
54
+ const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
55
55
  // Per-tool TTL + keyboard-strip are both wired into the sweep.
56
- expect(sweep).toContain('ttlForTool(')
56
+ expect(sweep).toContain('ttlForTool')
57
57
  expect(sweep).toContain('stripTimedOutPermissionCards(')
58
58
  })
59
59
 
@@ -91,14 +91,15 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
91
91
  // first interim reply. Without this, a new inbound delivered in that window
92
92
  // displaces the approval context and orphans the pending MCP call.
93
93
  it('turnInFlightForGate includes pendingPermissions.size > 0 on the legacy path', () => {
94
- // span 1500: the function has a ~800-char comment block before the code lines.
95
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
94
+ // span 2600: the function gained a ~1100-char note (#3084 follow-up) documenting
95
+ // the one case with no timeout valve — a HELD approval.
96
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
96
97
  // Both paths (legacy claudeBusyKeys + machine-authoritative) must include the check.
97
98
  expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0\s*\|\|\s*hasPendingApproval/)
98
99
  })
99
100
 
100
101
  it('turnInFlightForGate includes pendingPermissions.size > 0 on the machine path', () => {
101
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1600)
102
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2700)
102
103
  // probeGateParity(...) || hasPendingApproval — the inner arg contains its own
103
104
  // parens (isMachineInTurn()), so match the two tokens independently.
104
105
  expect(fn).toContain('probeGateParity(')
@@ -106,7 +107,7 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
106
107
  })
107
108
 
108
109
  it('hasPendingApproval reads pendingPermissions.size', () => {
109
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
110
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
110
111
  expect(fn).toMatch(/pendingPermissions\.size\s*>\s*0/)
111
112
  })
112
113
  })
@@ -163,7 +163,7 @@ describe('gateway boot-sweep grace period', () => {
163
163
 
164
164
  describe('re-armed pending card re-holds the inbound gate (#2840)', () => {
165
165
  it('turnInFlightForGate counts pending approvals', () => {
166
- const gate = slice(GATEWAY, 'function turnInFlightForGate()', 1200)
166
+ const gate = slice(GATEWAY, 'function turnInFlightForGate()', 2400)
167
167
  expect(gate).toMatch(/pendingPermissions\.size > 0/)
168
168
  })
169
169
  it('re-arm restores a pendingPermissions entry → gate held automatically', () => {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * #2975 Stage 2 — gateway-side read-only pre-approval predicate.
3
+ *
4
+ * `isDiffPreApproved` is what the gateway answers a hostd `check_pre_approved`
5
+ * query with. It MUST:
6
+ * - return true ONLY for a byte-exact registered (agent, diff) pair
7
+ * (mental-model proposal OR "🔁 Always allow" correlation);
8
+ * - be forge-resistant: a diff differing by ONE byte from the registered one
9
+ * is NOT pre-approved;
10
+ * - NEVER mutate the correlation store (the single-use auto-resolve delete
11
+ * stays on the real request_config_approval — a peek can't consume consent).
12
+ */
13
+
14
+ import { describe, it, expect, vi } from "vitest";
15
+ import { createHash } from "node:crypto";
16
+ import {
17
+ isDiffPreApproved,
18
+ type PreApprovalCheckDeps,
19
+ } from "../gateway/pre-approval-check.js";
20
+
21
+ const AGENT = "klanker";
22
+ const MM_DIFF =
23
+ "--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ mental_models: [x]\n";
24
+ const ALLOW_RULE = "Bash";
25
+ const ALLOW_DIFF =
26
+ "--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ - Bash\n";
27
+
28
+ function mmKey(agent: string, diff: string): string {
29
+ return `${agent}::${createHash("sha256").update(diff).digest("hex")}`;
30
+ }
31
+
32
+ /** A Map-backed store exposing only sweep()+get(), plus spies to prove no
33
+ * mutation happened (the ReadonlyCorrelationStore type has no delete/set, so
34
+ * a mutating edit wouldn't even typecheck — these spies double-check sweep). */
35
+ function makeStore(entries: Record<string, { unifiedDiff: string }>) {
36
+ const map = new Map(Object.entries(entries));
37
+ const sweep = vi.fn((_now: number) => {
38
+ /* no expiry in these tests */
39
+ });
40
+ return {
41
+ store: {
42
+ sweep,
43
+ get: (k: string) => map.get(k),
44
+ },
45
+ map,
46
+ sweep,
47
+ };
48
+ }
49
+
50
+ function makeDeps(overrides: {
51
+ alwaysAllow?: Record<string, { unifiedDiff: string }>;
52
+ mentalModel?: Record<string, { unifiedDiff: string }>;
53
+ }): {
54
+ deps: PreApprovalCheckDeps;
55
+ aaMap: Map<string, { unifiedDiff: string }>;
56
+ mmMap: Map<string, { unifiedDiff: string }>;
57
+ } {
58
+ const aa = makeStore(overrides.alwaysAllow ?? {});
59
+ const mm = makeStore(overrides.mentalModel ?? {});
60
+ const deps: PreApprovalCheckDeps = {
61
+ alwaysAllow: aa.store,
62
+ mentalModel: mm.store,
63
+ // Only recognise the ALLOW_RULE token when it appears as an added line.
64
+ extractAddedAllowRule: (diff) =>
65
+ diff.includes(`+ - ${ALLOW_RULE}`) ? ALLOW_RULE : null,
66
+ mentalModelCorrelationKey: mmKey,
67
+ };
68
+ return { deps, aaMap: aa.map, mmMap: mm.map };
69
+ }
70
+
71
+ describe("isDiffPreApproved — mental-model correlation", () => {
72
+ it("true for a byte-exact registered (agent, diff) pair", () => {
73
+ const { deps } = makeDeps({
74
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
75
+ });
76
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
77
+ });
78
+
79
+ it("false (forge-resistant) for a diff differing by one byte", () => {
80
+ const { deps } = makeDeps({
81
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
82
+ });
83
+ // The key is derived from the tampered diff → no entry at that key; and even
84
+ // if an attacker could guess the key, the stored unifiedDiff wouldn't match.
85
+ expect(isDiffPreApproved(AGENT, MM_DIFF + " ", deps)).toBe(false);
86
+ });
87
+
88
+ it("false for a matching diff under a DIFFERENT agent name", () => {
89
+ const { deps } = makeDeps({
90
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
91
+ });
92
+ expect(isDiffPreApproved("someone-else", MM_DIFF, deps)).toBe(false);
93
+ });
94
+
95
+ it("false when nothing is registered", () => {
96
+ const { deps } = makeDeps({});
97
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe("isDiffPreApproved — always-allow correlation", () => {
102
+ it("true for a byte-exact registered always-allow diff", () => {
103
+ const { deps } = makeDeps({
104
+ alwaysAllow: {
105
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
106
+ },
107
+ });
108
+ expect(isDiffPreApproved(AGENT, ALLOW_DIFF, deps)).toBe(true);
109
+ });
110
+
111
+ it("false when the added rule token matches but the diff bytes differ (forge)", () => {
112
+ const { deps } = makeDeps({
113
+ alwaysAllow: {
114
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
115
+ },
116
+ });
117
+ // Same rule token, but the diff smuggles an extra byte → byte-match fails.
118
+ expect(isDiffPreApproved(AGENT, ALLOW_DIFF + "\n", deps)).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("isDiffPreApproved — read-only invariant", () => {
123
+ it("never mutates either correlation store on a match", () => {
124
+ const { deps, mmMap } = makeDeps({
125
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
126
+ });
127
+ const sizeBefore = mmMap.size;
128
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
129
+ // The matched correlation is STILL present — a peek cannot consume the
130
+ // single-use consent the real request_config_approval will spend later.
131
+ expect(mmMap.size).toBe(sizeBefore);
132
+ expect(mmMap.has(mmKey(AGENT, MM_DIFF))).toBe(true);
133
+ // A second identical query still answers true (idempotent, no consumption).
134
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
135
+ });
136
+
137
+ it("never mutates the store on a miss", () => {
138
+ const { deps, aaMap, mmMap } = makeDeps({
139
+ alwaysAllow: {
140
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
141
+ },
142
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
143
+ });
144
+ expect(isDiffPreApproved(AGENT, "totally-unrelated-diff", deps)).toBe(false);
145
+ expect(aaMap.size).toBe(1);
146
+ expect(mmMap.size).toBe(1);
147
+ });
148
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * queued-card-store.test.ts — outcome-based tests for the queued-status /
3
+ * busy-ack card restart-survivability fix (#3002).
4
+ *
5
+ * PRE-FIX PROOF: before this change, no persistence existed for the
6
+ * `queuedStatusMsgIds` Map at all — `../gateway/queued-card-store.js` did not
7
+ * exist, so `import` itself failed at collection time and every test below
8
+ * failed on today's (pre-fix) base — there was no seam to test.
9
+ *
10
+ * This file exercises the REAL exported store + boot-reaper functions (not a
11
+ * structural grep), mirroring `activity-card-store.test.ts`'s in-memory fs seam
12
+ * convention, proving the exact outcome contract:
13
+ * (a) card post persists a record;
14
+ * (b) in-process reap clears it;
15
+ * (c) a boot reaper against a leftover record performs EXACTLY ONE Telegram
16
+ * delete and empties the store;
17
+ * (d) a second boot performs ZERO deletes;
18
+ * (e) a corrupt state file never crashes boot and is dropped without a delete;
19
+ * (f) a delete failure never crashes the reaper and still clears the row.
20
+ */
21
+ import { describe, it, expect } from 'vitest'
22
+ import {
23
+ loadQueuedCards,
24
+ persistQueuedCards,
25
+ writeQueuedCardRecord,
26
+ clearQueuedCardRecord,
27
+ runQueuedCardBootReaper,
28
+ type QueuedCardRecord,
29
+ type QueuedCardStoreFsSeam,
30
+ } from '../gateway/queued-card-store.js'
31
+
32
+ /** In-memory fs seam with an atomic rename — same convention as
33
+ * activity-card-store.test.ts's memFs, so the tmp→rename crash-safety
34
+ * contract is exercised without touching real disk. */
35
+ function memFs(seed: Record<string, string> = {}) {
36
+ const files = new Map<string, string>(Object.entries(seed))
37
+ const calls: string[] = []
38
+ const fs: QueuedCardStoreFsSeam = {
39
+ readFileSync: (p) => {
40
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
41
+ return files.get(p)!
42
+ },
43
+ writeFileSync: (p, d) => {
44
+ calls.push(`write:${p}`)
45
+ files.set(p, d)
46
+ },
47
+ renameSync: (a, b) => {
48
+ calls.push(`rename:${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, calls }
56
+ }
57
+
58
+ const PATH = '/state/agent/telegram/queued-cards-pending.json'
59
+
60
+ function card(over: Partial<QueuedCardRecord> = {}): QueuedCardRecord {
61
+ return {
62
+ key: '-100123:42',
63
+ chatId: '-100123',
64
+ threadId: 42,
65
+ messageId: 715,
66
+ ...over,
67
+ }
68
+ }
69
+
70
+ describe('queued-card-store — persistence primitives', () => {
71
+ it('loads [] when no file exists (fresh boot)', () => {
72
+ const { fs } = memFs()
73
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
74
+ })
75
+
76
+ it('(a) card post → state persisted (round-trips a written record)', () => {
77
+ const { fs } = memFs()
78
+ const record = card()
79
+ writeQueuedCardRecord(PATH, fs, record)
80
+ const loaded = loadQueuedCards(PATH, fs)
81
+ expect(loaded).toHaveLength(1)
82
+ expect(loaded[0]).toEqual(record)
83
+ })
84
+
85
+ it('round-trips a DM card (threadId null)', () => {
86
+ const { fs } = memFs()
87
+ const record = card({ key: '-100123:', threadId: null })
88
+ writeQueuedCardRecord(PATH, fs, record)
89
+ expect(loadQueuedCards(PATH, fs)[0]).toEqual(record)
90
+ })
91
+
92
+ it('a fresh post for the same topic replaces (upserts) the stale row, not appends', () => {
93
+ const { fs } = memFs()
94
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
95
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
96
+ const loaded = loadQueuedCards(PATH, fs)
97
+ expect(loaded).toHaveLength(1)
98
+ expect(loaded[0]!.messageId).toBe(900)
99
+ })
100
+
101
+ it('keeps rows for distinct keys (different topics) side by side', () => {
102
+ const { fs } = memFs()
103
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
104
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
105
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(2)
106
+ })
107
+
108
+ it('(b) in-process reap → state cleared', () => {
109
+ const { fs } = memFs()
110
+ writeQueuedCardRecord(PATH, fs, card())
111
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
112
+ clearQueuedCardRecord(PATH, fs, '-100123:42')
113
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
114
+ })
115
+
116
+ it('clearing an absent/mismatched key is a no-op (never throws)', () => {
117
+ const { fs } = memFs()
118
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:99' }))
119
+ clearQueuedCardRecord(PATH, fs, '-100123:42')
120
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
121
+ })
122
+
123
+ it('reap-race guard: clear scoped to messageId leaves a fresh live card intact', () => {
124
+ const { fs } = memFs()
125
+ // A live turn posted a NEW card (msg 900) under the same key while a stale
126
+ // reap tries to clear the OLD one (msg 715) — the new row must survive.
127
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
128
+ clearQueuedCardRecord(PATH, fs, '-100123:42', 715)
129
+ const loaded = loadQueuedCards(PATH, fs)
130
+ expect(loaded).toHaveLength(1)
131
+ expect(loaded[0]!.messageId).toBe(900)
132
+ })
133
+
134
+ it('writes via tmp + atomic rename (crash-safe)', () => {
135
+ const { fs, calls } = memFs()
136
+ persistQueuedCards(PATH, fs, [card()])
137
+ expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
138
+ })
139
+
140
+ it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
141
+ const { fs } = memFs({ [PATH]: '{not json' })
142
+ expect(() => loadQueuedCards(PATH, fs)).not.toThrow()
143
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
144
+ })
145
+
146
+ it('fail-open: wrong envelope version / malformed shape loads as []', () => {
147
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
148
+ expect(loadQueuedCards(PATH, f1)).toEqual([])
149
+ const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
150
+ expect(loadQueuedCards(PATH, f2)).toEqual([])
151
+ const { fs: f3 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: [{ key: '' }] }) })
152
+ expect(loadQueuedCards(PATH, f3)).toEqual([])
153
+ })
154
+ })
155
+
156
+ describe('queued-card-store — boot reaper (reap-on-boot, delete is the terminal)', () => {
157
+ it('(c) deletes each persisted card EXACTLY ONCE and empties the store', async () => {
158
+ const { fs } = memFs()
159
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
160
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
161
+ const deletes: number[] = []
162
+ const res = await runQueuedCardBootReaper({
163
+ path: PATH,
164
+ fs,
165
+ deleteCard: async (r) => {
166
+ deletes.push(r.messageId)
167
+ return { message_id: r.messageId }
168
+ },
169
+ })
170
+ expect(res).toEqual({ deleted: 2, total: 2 })
171
+ expect(deletes.sort()).toEqual([715, 716])
172
+ // Store is emptied — the stale cards can never be re-reaped.
173
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
174
+ })
175
+
176
+ it('(d) a SECOND boot performs ZERO deletes (store already drained)', async () => {
177
+ const { fs } = memFs()
178
+ writeQueuedCardRecord(PATH, fs, card())
179
+ await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
180
+ const deletes: number[] = []
181
+ const res = await runQueuedCardBootReaper({
182
+ path: PATH,
183
+ fs,
184
+ deleteCard: async (r) => {
185
+ deletes.push(r.messageId)
186
+ return {}
187
+ },
188
+ })
189
+ expect(res).toEqual({ deleted: 0, total: 0 })
190
+ expect(deletes).toEqual([])
191
+ })
192
+
193
+ it('deletes the record BEFORE the delete call (idempotency guard — no double-delete on crash)', async () => {
194
+ const { fs } = memFs()
195
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
196
+ let storeAtDeleteTime: QueuedCardRecord[] | null = null
197
+ await runQueuedCardBootReaper({
198
+ path: PATH,
199
+ fs,
200
+ deleteCard: async () => {
201
+ // At the moment the Telegram delete runs, the row is already gone from
202
+ // disk — a crash here or a concurrent second boot cannot re-issue it.
203
+ storeAtDeleteTime = loadQueuedCards(PATH, fs)
204
+ return {}
205
+ },
206
+ })
207
+ expect(storeAtDeleteTime).toEqual([])
208
+ })
209
+
210
+ it('(f) a delete FAILURE never crashes the reaper and still clears the row (best-effort, tolerate "not found")', async () => {
211
+ const { fs } = memFs()
212
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
213
+ const res = await runQueuedCardBootReaper({
214
+ path: PATH,
215
+ fs,
216
+ deleteCard: async () => {
217
+ throw new Error('Bad Request: message to delete not found')
218
+ },
219
+ log: () => {},
220
+ })
221
+ // Not counted as deleted, but the row is dropped (delete-first) so it can't
222
+ // wedge every future boot.
223
+ expect(res).toEqual({ deleted: 0, total: 1 })
224
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
225
+ })
226
+
227
+ it('empty store: reaper is a no-op', async () => {
228
+ const { fs } = memFs()
229
+ const res = await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
230
+ expect(res).toEqual({ deleted: 0, total: 0 })
231
+ })
232
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * #2094 finding 3 — reaction-flush uses the #1556 turn gate.
3
+ *
4
+ * THE BUG: flushReactionBatch delivered its synthetic reaction inbound with a
5
+ * bare `ipcServer.sendToAgent` + busy-mark, buffering ONLY on a bridge-offline
6
+ * miss. It never ran the #1556 decideInboundDelivery gate. So a reaction that
7
+ * landed WHILE a turn was in flight fired the MCP channel notification mid-turn
8
+ * — the unmodified CLI typed it into its TUI composer and the auto-submit raced
9
+ * turn-completion, re-creating the composer wedge.
10
+ *
11
+ * THE FIX: route the flush through deliverResumeSyntheticOrBuffer, the shared
12
+ * helper that consults the gate (mid-turn → buffer-until-idle, flushed by the
13
+ * turn-complete hook + idle-drain timer; idle → deliver now; buffer only on a
14
+ * genuine miss).
15
+ *
16
+ * gateway.ts is a large side-effecting module that can't be imported into a
17
+ * unit test, so this file pins (a) the gate decision for a reaction inbound's
18
+ * shape via the pure gate + a modeled buffer/drain, and (b) that
19
+ * flushReactionBatch wires the turn-safe helper structurally — the same
20
+ * strategy button-tap-turn-gated.test.ts uses.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import { readFileSync } from 'node:fs'
24
+ import { resolve } from 'node:path'
25
+ import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
26
+ import { planBufferedRedelivery } from '../gateway/pending-inbound-buffer.js'
27
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
28
+
29
+ const gatewaySrc = readFileSync(
30
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
31
+ 'utf-8',
32
+ )
33
+
34
+ // A reaction inbound as flushReactionBatch builds it: synthetic messageId (ts),
35
+ // never steering, never an interrupt — the exact inputs the shared helper
36
+ // (deliverResumeSyntheticOrBuffer) passes to the gate.
37
+ function reactionInbound(ts: number): InboundMessage {
38
+ return {
39
+ type: 'inbound',
40
+ chatId: 'c1',
41
+ messageId: ts,
42
+ user: 'u',
43
+ userId: 42,
44
+ ts,
45
+ text: `[user reacted 👍 to your message]`,
46
+ meta: { chat_id: 'c1', count: '1' },
47
+ }
48
+ }
49
+
50
+ describe('reaction flush uses the turn-gate (mid-turn → buffer)', () => {
51
+ it('buffers a reaction that lands mid-turn, delivers when idle, never mid-turn', () => {
52
+ const shape = { isSteering: false as const, isInterrupt: false as const }
53
+ // Reaction lands WHILE a turn is in flight → HELD, not sent (the wedge the
54
+ // raw sendToAgent used to cause).
55
+ expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
56
+ 'buffer-until-idle',
57
+ )
58
+ // Reaction lands at an idle prompt → delivered immediately.
59
+ expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
60
+ 'deliver',
61
+ )
62
+ })
63
+
64
+ it('a mid-turn-buffered reaction drains as a fresh turn after turn end', () => {
65
+ // Model the buffer→drain: the reaction was held mid-turn; on turn-complete
66
+ // the buffer is planned for redelivery and the reaction lands cleanly as
67
+ // its own turn (never merged away / dropped).
68
+ const plan = planBufferedRedelivery([reactionInbound(1000)])
69
+ expect(plan).toHaveLength(1)
70
+ expect(plan[0]!.merged.text).toBe('[user reacted 👍 to your message]')
71
+ })
72
+ })
73
+
74
+ describe('flushReactionBatch wires the turn-safe helper', () => {
75
+ const start = gatewaySrc.indexOf('function flushReactionBatch(batch: ReactionBatch)')
76
+ // Bound the body by the next top-level declaration (the message_reaction
77
+ // handler block).
78
+ const body = gatewaySrc.slice(
79
+ start,
80
+ gatewaySrc.indexOf("bot.on('message_reaction'", start),
81
+ )
82
+
83
+ it('exists', () => {
84
+ expect(start, 'flushReactionBatch missing').toBeGreaterThan(0)
85
+ })
86
+
87
+ it('delivers via deliverResumeSyntheticOrBuffer, not a raw ungated sendToAgent', () => {
88
+ expect(body).toMatch(/deliverResumeSyntheticOrBuffer\(agentName, inbound\)/)
89
+ // The regressed path — a raw ungated send of the reaction inbound + an
90
+ // out-of-helper busy-mark — is gone.
91
+ expect(body).not.toMatch(/ipcServer\.sendToAgent\(agentName, inbound\)/)
92
+ expect(body).not.toMatch(/markClaudeBusyForInbound\(inbound\)/)
93
+ })
94
+
95
+ it('no longer open-codes the buffer-on-failure push (the helper owns it)', () => {
96
+ // The #1150 buffer-on-failure guarantee now lives inside
97
+ // deliverResumeSyntheticOrBuffer, so the flush body must not push directly.
98
+ expect(body).not.toMatch(/pendingInboundBuffer\.push\(/)
99
+ })
100
+ })