switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Regression guard — agent-authored inline-keyboard button taps (`agent:`
3
+ * callback_data) are turn-safe (#271 button path, the mid-turn-strand).
4
+ *
5
+ * THE BUG: the `agent:` callback handler delivered the synthesized tap
6
+ * inbound with a bare `ipcServer.sendToAgent` + busy-mark, and buffered
7
+ * ONLY when the bridge was offline (`delivered=false`). It never ran the
8
+ * #1556 turn gate the normal inbound path uses. So a tap landing WHILE a
9
+ * turn was in flight fired the MCP channel notification mid-turn, the
10
+ * unmodified CLI typed it into its TUI composer, and the auto-submit raced
11
+ * turn-completion — the tap stranded in the composer with nothing to rescue
12
+ * it (the buffer only catches bridge-offline). It also skipped the pre-send
13
+ * composer clear and the deliver-until-acked tracking, so a stranded tap was
14
+ * never sweep-redelivered.
15
+ *
16
+ * THE FIX: the tap routes through `deliverButtonTapInbound`, which consults
17
+ * the SAME turn gate (mid-turn → `buffer-until-idle`, flushed at turn-end),
18
+ * clears the composer before sending, and enrols the delivery in the
19
+ * deliver-until-acked queue so the sweep rescues a strand. Bridge-offline
20
+ * still spools + shows the restart notice (unchanged UX).
21
+ *
22
+ * gateway.ts is a large side-effecting module (top-level `bot.on`, ipc
23
+ * server, timers) that can't be imported into a unit test, so this file
24
+ * pins (a) the gate decision for a button tap's shape via the pure gate, and
25
+ * (b) that the handler + helper wire the turn-safe machinery structurally —
26
+ * exactly the strategy `vault-resume-turn-gated.test.ts` uses for the resume
27
+ * synthetics.
28
+ */
29
+ import { describe, it, expect } from "vitest";
30
+ import { readFileSync } from "node:fs";
31
+ import { resolve } from "node:path";
32
+ import { decideInboundDelivery } from "../gateway/inbound-delivery-gate.js";
33
+ import { shouldTrackDelivery } from "../gateway/inbound-delivery-confirm.js";
34
+ import { planBufferedRedelivery } from "../gateway/pending-inbound-buffer.js";
35
+ import type { InboundMessage } from "../gateway/ipc-protocol.js";
36
+
37
+ const gatewaySrc = readFileSync(
38
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
39
+ "utf-8",
40
+ );
41
+
42
+ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
43
+ it("a button tap's gate shape buffers mid-turn, delivers when idle", () => {
44
+ // A button tap is never steering and never an interrupt — the exact
45
+ // inputs deliverButtonTapInbound passes to the gate.
46
+ const shape = { isSteering: false as const, isInterrupt: false as const };
47
+ // Tap lands WHILE a turn is in flight → held until claude goes idle
48
+ // (the mid-turn strand the raw sendToAgent used to cause).
49
+ expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
50
+ "buffer-until-idle",
51
+ );
52
+ // Tap lands at an idle prompt → delivered immediately.
53
+ expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
54
+ "deliver",
55
+ );
56
+ });
57
+
58
+ it("a button tap is enrolled by shouldTrackDelivery (no source, non-empty body)", () => {
59
+ // The tap inbound carries no meta.source and a non-empty
60
+ // `[user tapped button: …]` body, so it IS tracked — the strand it can
61
+ // suffer must be re-deliverable by the sweep.
62
+ expect(
63
+ shouldTrackDelivery({
64
+ isSteering: false,
65
+ isInterrupt: false,
66
+ hasSource: false,
67
+ effectiveText: "[user tapped button: approve_pr_547]",
68
+ }),
69
+ ).toBe(true);
70
+ });
71
+
72
+ describe("deliverButtonTapInbound helper", () => {
73
+ const start = gatewaySrc.indexOf(
74
+ "async function deliverButtonTapInbound",
75
+ );
76
+ // Bound the helper body by the next top-level declaration — it carries the
77
+ // gate, composer-clear, send, tracking, and bridge-offline branches.
78
+ const body = gatewaySrc.slice(
79
+ start,
80
+ gatewaySrc.indexOf("const pendingRestarts", start),
81
+ );
82
+
83
+ it("exists and is async (composer-clear await)", () => {
84
+ expect(start, "deliverButtonTapInbound helper missing").toBeGreaterThan(0);
85
+ expect(body).toMatch(
86
+ /async function deliverButtonTapInbound\(\s*agent: string,\s*inbound: InboundMessage,\s*\): Promise<ButtonTapDeliveryOutcome>/,
87
+ );
88
+ });
89
+
90
+ it("consults the turn gate BEFORE any send, buffering mid-turn", () => {
91
+ const gateIdx = body.indexOf("reserveInboundDelivery(");
92
+ const bufIdx = body.indexOf("pendingInboundBuffer.push(");
93
+ const sendIdx = body.indexOf("ipcServer.sendToAgent(");
94
+ expect(gateIdx, "gate must be consulted").toBeGreaterThan(0);
95
+ expect(sendIdx, "helper must still deliver in the idle branch").toBeGreaterThan(gateIdx);
96
+ // The buffer-until-idle branch buffers BEFORE the send branch — the tap
97
+ // is never delivered mid-turn.
98
+ expect(bufIdx, "must buffer in the mid-turn branch").toBeGreaterThan(gateIdx);
99
+ expect(bufIdx, "buffer-until-idle precedes the send").toBeLessThan(sendIdx);
100
+ expect(body).toMatch(/decision === 'buffer-until-idle'/);
101
+ expect(body).toMatch(/return 'buffered-mid-turn'/);
102
+ });
103
+
104
+ it("clears the composer before the send (the marko wedge)", () => {
105
+ const clearIdx = body.indexOf("clearAgentComposer");
106
+ const sendIdx = body.indexOf("ipcServer.sendToAgent(");
107
+ expect(clearIdx, "must import clearAgentComposer").toBeGreaterThan(0);
108
+ expect(clearIdx, "composer clear precedes the send").toBeLessThan(sendIdx);
109
+ });
110
+
111
+ it("tracks the delivery so the deliver-until-acked sweep rescues a strand", () => {
112
+ expect(body).toMatch(/DELIVERY_CONFIRM_ENABLED/);
113
+ expect(body).toMatch(/shouldTrackDelivery\(/);
114
+ expect(body).toMatch(/trackDelivery\(deliveryQueue,/);
115
+ });
116
+
117
+ it("buffers + spools on bridge-offline (existing behaviour intact)", () => {
118
+ // The idle-branch send missed → the reservation is released in lockstep
119
+ // and the tap is spooled to the pending-inbound buffer for replay.
120
+ expect(body).toMatch(/claudeBusyKeys\.delete\(reservedBusyKey\)/);
121
+ expect(body).toMatch(/return 'buffered-bridge-offline'/);
122
+ // The bridge-offline buffer push is the LAST push (after the send miss),
123
+ // so it always spools before returning the offline outcome.
124
+ const offlineIdx = body.indexOf("return 'buffered-bridge-offline'");
125
+ const lastBufIdx = body.lastIndexOf("pendingInboundBuffer.push(");
126
+ expect(lastBufIdx).toBeGreaterThan(0);
127
+ expect(lastBufIdx, "spool precedes the offline return").toBeLessThan(offlineIdx);
128
+ });
129
+ });
130
+
131
+ describe("the agent: callback handler routes through the helper", () => {
132
+ it("delivers the tap via deliverButtonTapInbound, not a raw sendToAgent", () => {
133
+ // Locate the agent-callback handler block.
134
+ const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
135
+ expect(anchor, "agent-callback handler missing").toBeGreaterThan(0);
136
+ const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
137
+ // The tap is routed through the turn-safe helper...
138
+ expect(handler).toMatch(/await deliverButtonTapInbound\(selfAgentBtn, inboundMsg\)/);
139
+ // ...and NOT via a raw ungated sendToAgent of the tap inbound (the
140
+ // regressed path that stranded the tap mid-turn).
141
+ expect(handler).not.toMatch(/ipcServer\.sendToAgent\(selfAgentBtn, inboundMsg\)/);
142
+ });
143
+
144
+ it("still shows the restart notice only on bridge-offline", () => {
145
+ const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
146
+ const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
147
+ // The restart notice is gated on the bridge-offline outcome — a mid-turn
148
+ // buffered tap must NOT nag the user (it will be actioned at idle).
149
+ expect(handler).toMatch(/btnOutcome === 'buffered-bridge-offline'/);
150
+ expect(handler).toMatch(/button-tap-restarting-notice/);
151
+ });
152
+
153
+ it("preserves the ack toast and single-use keyboard strip (UX unchanged)", () => {
154
+ const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
155
+ const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
156
+ // Ack toast fires FIRST (before delivery) so the tap always registers…
157
+ const ackIdx = handler.indexOf("answerCallbackQuery({ text: ackText })");
158
+ const deliverIdx = handler.indexOf("deliverButtonTapInbound(");
159
+ expect(ackIdx, "ack toast must fire").toBeGreaterThan(0);
160
+ expect(ackIdx, "ack toast fires before delivery").toBeLessThan(deliverIdx);
161
+ // …and the single-use keyboard strip still runs after.
162
+ expect(handler).toMatch(/keyboardIsSingleUse\(metaForMessage\)/);
163
+ expect(handler).toMatch(/editMessageReplyMarkup/);
164
+ });
165
+ });
166
+
167
+ describe("flush-path anti-storm guard (trackRedeliveredInbound)", () => {
168
+ it("skips tracking an id-less button tap, mirroring the immediate path", () => {
169
+ // The immediate path (deliverButtonTapInbound) refuses to enrol a tap
170
+ // without a real meta.message_id — the `enqueue` ack could never match
171
+ // it, so the never-drop sweep would re-fire until TTL. A tap that
172
+ // buffered mid-turn and flushed through trackRedeliveredInbound must be
173
+ // skipped by the SAME rule, or the storm exists on one path only.
174
+ const start = gatewaySrc.indexOf("function trackRedeliveredInbound");
175
+ expect(start, "trackRedeliveredInbound missing").toBeGreaterThan(0);
176
+ const body = gatewaySrc.slice(
177
+ start,
178
+ gatewaySrc.indexOf("\n}", start) + 2,
179
+ );
180
+ // The guard: button_callback-tagged AND no meta.message_id → return
181
+ // before trackDelivery.
182
+ const guardIdx = body.indexOf("merged.meta?.button_callback === 'true'");
183
+ const trackIdx = body.indexOf("trackDelivery(");
184
+ expect(guardIdx, "button-tap message_id guard missing").toBeGreaterThan(0);
185
+ expect(guardIdx, "guard must precede trackDelivery").toBeLessThan(trackIdx);
186
+ expect(body).toMatch(
187
+ /merged\.meta\.message_id == null \|\| merged\.meta\.message_id === ''/,
188
+ );
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("buffered button taps never merge with adjacent user messages", () => {
194
+ const userMsg = (text: string, ts: number): InboundMessage => ({
195
+ type: "inbound",
196
+ chatId: "c1",
197
+ messageId: ts,
198
+ user: "u",
199
+ userId: 42,
200
+ ts,
201
+ text,
202
+ meta: { chat_id: "c1", user: "u", user_id: "42", ts: String(ts) },
203
+ });
204
+ // A tap inbound as the agent: callback handler builds it — same chat/user
205
+ // as the surrounding texts, no meta.source, button_callback tagged.
206
+ const tapMsg = (raw: string, ts: number): InboundMessage => ({
207
+ type: "inbound",
208
+ chatId: "c1",
209
+ messageId: ts,
210
+ user: "u",
211
+ userId: 42,
212
+ ts,
213
+ text: `[user tapped button: ${raw}]`,
214
+ meta: {
215
+ chat_id: "c1",
216
+ message_id: String(ts),
217
+ user: "u",
218
+ user_id: "42",
219
+ ts: String(ts),
220
+ button_callback: "true",
221
+ button_callback_data: raw,
222
+ button_text: "Approve",
223
+ },
224
+ });
225
+
226
+ it("a mid-turn tap buffered between user texts delivers individually", () => {
227
+ // Without the exclusion, the source-less tap merged with the adjacent
228
+ // texts and mergeRun kept only the anchor (last) message's meta —
229
+ // silently dropping button_callback_data/button_text whenever a text was
230
+ // last. The agent then saw the human-readable tap line without the
231
+ // machine-readable payload.
232
+ const plan = planBufferedRedelivery([
233
+ userMsg("first", 1),
234
+ tapMsg("approve_pr_547", 2),
235
+ userMsg("second", 3),
236
+ ]);
237
+ expect(plan.map((p) => p.merged.text)).toEqual([
238
+ "first",
239
+ "[user tapped button: approve_pr_547]",
240
+ "second",
241
+ ]);
242
+ // The tap keeps its full button meta intact.
243
+ const tap = plan[1]!.merged;
244
+ expect(tap.meta.button_callback).toBe("true");
245
+ expect(tap.meta.button_callback_data).toBe("approve_pr_547");
246
+ expect(tap.meta.button_text).toBe("Approve");
247
+ });
248
+
249
+ it("a tap followed by a user text does not merge (text-last would drop the payload)", () => {
250
+ const plan = planBufferedRedelivery([
251
+ tapMsg("hold", 1),
252
+ userMsg("also do the thing", 2),
253
+ ]);
254
+ expect(plan).toHaveLength(2);
255
+ expect(plan[0]!.merged.meta.button_callback_data).toBe("hold");
256
+ });
257
+
258
+ it("plain user texts still merge around the exclusion (no regression)", () => {
259
+ const plan = planBufferedRedelivery([userMsg("a", 1), userMsg("b", 2)]);
260
+ expect(plan).toHaveLength(1);
261
+ expect(plan[0]!.merged.text).toBe("a\nb");
262
+ });
263
+ });
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
43
43
  // window to ~5KB so we don't pick up the unrelated
44
44
  // `removeTurnActiveMarker` call in the onTurnComplete handler
45
45
  // way down at the bottom of the file.
46
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
46
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
47
47
  const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
48
48
  expect(clearMatches.length).toBe(1)
49
49
  })
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
53
53
  // must not prevent the gateway from coming up. Pin the try-wrapping
54
54
  // to make sure a future refactor doesn't drop it.
55
55
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
56
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
56
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
57
57
  expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
58
58
  })
59
59
 
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
62
62
  // Clearing the marker first means the watchdog sees a clean slate
63
63
  // immediately, even if pin sweep takes 30s+ to finish.
64
64
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
65
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
65
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
66
66
  const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
67
67
  const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
68
68
  expect(clearIdx).toBeGreaterThan(0)
@@ -36,6 +36,7 @@ import {
36
36
  clearCleanShutdownMarker,
37
37
  shouldSuppressRecoveryBanner,
38
38
  shouldSuppressBootResume,
39
+ parseBootResumeMode,
39
40
  resolveShutdownMarker,
40
41
  DEFAULT_MAX_AGE_MS,
41
42
  EXTERNAL_RESTART_FALLBACK_REASON,
@@ -350,77 +351,117 @@ describe("resolveShutdownMarker (SIGTERM-handler sequencing)", () => {
350
351
  // ---------------------------------------------------------------------------
351
352
 
352
353
  describe("shouldSuppressBootResume", () => {
353
- // Core contract: clean shutdown (fresh marker) suppress; crash (no marker
354
- // or stale) do not suppress; forceAlways override never suppress.
354
+ // Product decision 2026-07 (supersedes #2585): the DEFAULT mode is
355
+ // 'in-flight' a genuinely in-flight turn resumes even after a clean /
356
+ // deliberate restart, so a fresh clean marker NO LONGER suppresses by
357
+ // default. Suppression survives only as the opt-in 'never' mode.
358
+
359
+ it("DEFAULT (in-flight): fresh clean marker does NOT suppress — in-flight work resumes", () => {
360
+ // This is the core regression fix: a deliberate restart landing mid-turn
361
+ // must not silently drop the work.
362
+ const now = 1_700_000_000_000;
363
+ const marker: CleanShutdownMarker = { ts: now - 5_000, signal: "SIGTERM" };
364
+ expect(shouldSuppressBootResume(marker, now)).toBe(false);
365
+ // Explicit 'in-flight' behaves identically to the default.
366
+ expect(shouldSuppressBootResume(marker, now, { mode: "in-flight" })).toBe(false);
367
+ });
355
368
 
356
- it("returns false when no marker is present (crash/OOM — resume as before)", () => {
357
- expect(shouldSuppressBootResume(null, Date.now())).toBe(false);
369
+ it("mode 'never': returns false when no marker is present (crash/OOM — resume)", () => {
370
+ expect(shouldSuppressBootResume(null, Date.now(), { mode: "never" })).toBe(false);
358
371
  });
359
372
 
360
- it("returns true for a fresh clean-shutdown marker (operator/roll restart — suppress)", () => {
373
+ it("mode 'never': returns true for a fresh clean-shutdown marker (suppress)", () => {
361
374
  const now = 1_700_000_000_000;
362
375
  const marker: CleanShutdownMarker = { ts: now - 5_000, signal: "SIGTERM" };
363
- expect(shouldSuppressBootResume(marker, now)).toBe(true);
376
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(true);
364
377
  });
365
378
 
366
- it("returns true at age=0 (marker written right before boot)", () => {
379
+ it("mode 'never': returns true at age=0 (marker written right before boot)", () => {
367
380
  const now = 1_700_000_000_000;
368
381
  const marker: CleanShutdownMarker = { ts: now, signal: "SIGTERM" };
369
- expect(shouldSuppressBootResume(marker, now)).toBe(true);
382
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(true);
370
383
  });
371
384
 
372
- it("returns false when marker age equals maxAgeMs (boundary is exclusive)", () => {
385
+ it("mode 'never': false when marker age equals maxAgeMs (boundary is exclusive)", () => {
373
386
  const now = 1_700_000_000_000;
374
387
  const marker: CleanShutdownMarker = { ts: now - DEFAULT_MAX_AGE_MS, signal: "SIGTERM" };
375
- expect(shouldSuppressBootResume(marker, now)).toBe(false);
388
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(false);
376
389
  });
377
390
 
378
- it("returns false for a stale marker (drain took >60s — treat as crash)", () => {
391
+ it("mode 'never': false for a stale marker (drain took >60s — slow rollout resumes)", () => {
392
+ // Preserves the ">60s already resumes" behaviour even under 'never'.
379
393
  const now = 1_700_000_000_000;
380
394
  const marker: CleanShutdownMarker = { ts: now - 90_000, signal: "SIGTERM" };
381
- expect(shouldSuppressBootResume(marker, now)).toBe(false);
395
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(false);
382
396
  });
383
397
 
384
- it("treats clock skew (future ts) as stale to avoid false suppression", () => {
398
+ it("mode 'never': treats clock skew (future ts) as stale to avoid false suppression", () => {
385
399
  const now = 1_700_000_000_000;
386
400
  const marker: CleanShutdownMarker = { ts: now + 10_000, signal: "SIGTERM" };
387
- expect(shouldSuppressBootResume(marker, now)).toBe(false);
401
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(false);
388
402
  });
389
403
 
390
- it("respects a custom maxAgeMs", () => {
404
+ it("mode 'never': respects a custom maxAgeMs", () => {
391
405
  const now = 1_700_000_000_000;
392
406
  const marker: CleanShutdownMarker = { ts: now - 30_000, signal: "SIGTERM" };
393
- expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 60_000 })).toBe(true);
394
- expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 10_000 })).toBe(false);
407
+ expect(shouldSuppressBootResume(marker, now, { mode: "never", maxAgeMs: 60_000 })).toBe(true);
408
+ expect(shouldSuppressBootResume(marker, now, { mode: "never", maxAgeMs: 10_000 })).toBe(false);
395
409
  });
396
410
 
397
- it("forceAlways=true disables suppression even for a fresh clean marker (escape hatch)", () => {
411
+ it("forceAlways=true disables suppression even under 'never' + fresh marker (escape hatch)", () => {
398
412
  // SWITCHROOM_BOOT_RESUME_ALWAYS=1 must restore unconditional resume.
399
413
  const now = 1_700_000_000_000;
400
414
  const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
401
- expect(shouldSuppressBootResume(marker, now, { forceAlways: true })).toBe(false);
415
+ expect(shouldSuppressBootResume(marker, now, { mode: "never", forceAlways: true })).toBe(false);
402
416
  });
403
417
 
404
- it("forceAlways=false has no effect (default behaviour is the gate)", () => {
418
+ it("mode 'always': never suppresses, even with a fresh clean marker", () => {
405
419
  const now = 1_700_000_000_000;
406
420
  const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
407
- expect(shouldSuppressBootResume(marker, now, { forceAlways: false })).toBe(true);
421
+ expect(shouldSuppressBootResume(marker, now, { mode: "always" })).toBe(false);
408
422
  });
409
423
 
410
- it("works for SIGTERM and SIGINT (signal value is opaque)", () => {
424
+ it("mode 'never': works for SIGTERM and SIGINT (signal value is opaque)", () => {
411
425
  const now = 1_700_000_000_000;
412
- expect(shouldSuppressBootResume({ ts: now, signal: "SIGTERM" }, now)).toBe(true);
413
- expect(shouldSuppressBootResume({ ts: now, signal: "SIGINT" }, now)).toBe(true);
426
+ expect(shouldSuppressBootResume({ ts: now, signal: "SIGTERM" }, now, { mode: "never" })).toBe(true);
427
+ expect(shouldSuppressBootResume({ ts: now, signal: "SIGINT" }, now, { mode: "never" })).toBe(true);
414
428
  });
415
429
 
416
- it("works with a marker that carries a reason field (rollout attribution is preserved)", () => {
430
+ it("mode 'never': works with a marker that carries a reason field", () => {
417
431
  const now = 1_700_000_000_000;
418
432
  const marker: CleanShutdownMarker = {
419
433
  ts: now - 2_000,
420
434
  signal: "SIGTERM",
421
435
  reason: "operator: switchroom update",
422
436
  };
423
- expect(shouldSuppressBootResume(marker, now)).toBe(true);
437
+ expect(shouldSuppressBootResume(marker, now, { mode: "never" })).toBe(true);
438
+ });
439
+ });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Boot-resume mode parsing
443
+ // ---------------------------------------------------------------------------
444
+
445
+ describe("parseBootResumeMode", () => {
446
+ it("defaults to 'in-flight' for undefined/null/empty/unknown", () => {
447
+ expect(parseBootResumeMode(undefined)).toBe("in-flight");
448
+ expect(parseBootResumeMode(null)).toBe("in-flight");
449
+ expect(parseBootResumeMode("")).toBe("in-flight");
450
+ expect(parseBootResumeMode("bogus")).toBe("in-flight");
451
+ });
452
+
453
+ it("parses the three canonical values", () => {
454
+ expect(parseBootResumeMode("always")).toBe("always");
455
+ expect(parseBootResumeMode("in-flight")).toBe("in-flight");
456
+ expect(parseBootResumeMode("never")).toBe("never");
457
+ });
458
+
459
+ it("is case- and separator-insensitive", () => {
460
+ expect(parseBootResumeMode("ALWAYS")).toBe("always");
461
+ expect(parseBootResumeMode("In-Flight")).toBe("in-flight");
462
+ expect(parseBootResumeMode("in_flight")).toBe("in-flight");
463
+ expect(parseBootResumeMode("inflight")).toBe("in-flight");
464
+ expect(parseBootResumeMode(" never ")).toBe("never");
424
465
  });
425
466
  });
426
467
 
@@ -446,9 +487,14 @@ describe("gateway.ts boot-resume clean-shutdown gate (source-level)", () => {
446
487
  expect(gatewaySource).toContain("readCleanShutdownMarker(bootResumeMarkerPath)");
447
488
  });
448
489
 
449
- it("calls shouldSuppressBootResume with the marker, now, and forceAlways", () => {
490
+ it("calls shouldSuppressBootResume with the marker, now, forceAlways, and mode", () => {
450
491
  expect(gatewaySource).toContain("shouldSuppressBootResume(bootResumeCleanMarker, Date.now()");
451
492
  expect(gatewaySource).toContain("forceAlways: bootResumeForceAlways");
493
+ expect(gatewaySource).toContain("mode: bootResumeMode");
494
+ });
495
+
496
+ it("parses the boot-resume mode from SWITCHROOM_BOOT_RESUME", () => {
497
+ expect(gatewaySource).toContain("parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)");
452
498
  });
453
499
 
454
500
  it("provides the SWITCHROOM_BOOT_RESUME_ALWAYS escape hatch", () => {
@@ -459,6 +505,18 @@ describe("gateway.ts boot-resume clean-shutdown gate (source-level)", () => {
459
505
  it("logs a diagnostic when boot-resume is suppressed", () => {
460
506
  expect(gatewaySource).toContain("boot-resume suppressed (clean shutdown");
461
507
  });
508
+
509
+ it("still delivers a passive report (never silence) when resume is suppressed", () => {
510
+ // Requirement: silence is never acceptable when work was in flight.
511
+ // The suppressed path builds a deferred-report inbound, not nothing.
512
+ expect(gatewaySource).toContain("defer-suppressed");
513
+ expect(gatewaySource).toContain("buildResumeDeferredReportInbound");
514
+ });
515
+
516
+ it("caps the resume chain with a loop-guard (no unbounded resume-of-resume)", () => {
517
+ expect(gatewaySource).toContain("decideBootResumeKind");
518
+ expect(gatewaySource).toContain("defer-loop");
519
+ });
462
520
  });
463
521
 
464
522
  describe("gateway.ts shutdown-handler wiring (source-level)", () => {
@@ -26,10 +26,12 @@ describe('gateway: scheduleModelRelaunch dep', () => {
26
26
  expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
27
27
  })
28
28
 
29
- it('sets the in-memory activeSessionModelOverride before dispatching the restart', () => {
29
+ it('sets the in-memory session-model override before dispatching the restart', () => {
30
30
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
31
31
  const win = GATEWAY_SRC.slice(idx, idx + 900)
32
- const setIdx = win.indexOf('activeSessionModelOverride = model')
32
+ // The override now lives on the freshness-aware sessionModelSource
33
+ // (session-model-source.ts) rather than a bare module-level variable.
34
+ const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
33
35
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
34
36
  expect(setIdx).toBeGreaterThan(0)
35
37
  expect(restartIdx).toBeGreaterThan(setIdx)
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Issue #2971 — gateway-side handler for `query_pending_permission`, the
3
+ * read-only wedge-watchdog probe that lets the permission-prompt branch
4
+ * defer to a live Telegram approval card instead of racing it with Esc.
5
+ *
6
+ * Exercises the REAL createIpcServer over a real (tmp) unix socket with a
7
+ * raw `net.createConnection` client — the same transport the sidecar's
8
+ * `permission-pending-query.ts` module uses (no bridge/register handshake
9
+ * required; this message can be sent by ANY process inside the container,
10
+ * same trust model as `quota_wall_detected`).
11
+ */
12
+ import { describe, it, expect, afterEach } from "vitest";
13
+ import { createConnection } from "node:net";
14
+ import { mkdtempSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { createIpcServer, type IpcServer, type IpcClient } from "../gateway/ipc-server.js";
18
+ import { validateClientMessage } from "../gateway/ipc-server.js";
19
+ import type { QueryPendingPermissionMessage } from "../gateway/ipc-protocol.js";
20
+
21
+ function tmpSocket(): string {
22
+ const dir = mkdtempSync(join(tmpdir(), "ipc-qpp-test-"));
23
+ return join(dir, "test.sock");
24
+ }
25
+
26
+ function wait(ms: number): Promise<void> {
27
+ return new Promise((r) => setTimeout(r, ms));
28
+ }
29
+
30
+ /** Send one query and collect every `pending_permission_status` reply line. */
31
+ function queryOnce(socketPath: string, agentName: string, correlationId: string): Promise<any> {
32
+ return new Promise((resolve, reject) => {
33
+ const sock = createConnection(socketPath);
34
+ let buffer = "";
35
+ sock.on("connect", () => {
36
+ sock.write(JSON.stringify({ type: "query_pending_permission", agentName, correlationId } as QueryPendingPermissionMessage) + "\n");
37
+ });
38
+ sock.on("data", (chunk) => {
39
+ buffer += chunk.toString("utf8");
40
+ const idx = buffer.indexOf("\n");
41
+ if (idx >= 0) {
42
+ const line = buffer.slice(0, idx);
43
+ sock.end();
44
+ resolve(JSON.parse(line));
45
+ }
46
+ });
47
+ sock.on("error", reject);
48
+ });
49
+ }
50
+
51
+ describe("validateClientMessage — query_pending_permission (#2971)", () => {
52
+ it("accepts a well-formed query", () => {
53
+ expect(
54
+ validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "abc" }),
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("rejects missing/malformed agentName", () => {
59
+ expect(validateClientMessage({ type: "query_pending_permission", correlationId: "abc" })).toBe(false);
60
+ expect(validateClientMessage({ type: "query_pending_permission", agentName: "", correlationId: "abc" })).toBe(false);
61
+ expect(validateClientMessage({ type: "query_pending_permission", agentName: "Bad Name", correlationId: "abc" })).toBe(false);
62
+ });
63
+
64
+ it("rejects missing/oversized correlationId", () => {
65
+ expect(validateClientMessage({ type: "query_pending_permission", agentName: "carrie" })).toBe(false);
66
+ expect(validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "" })).toBe(false);
67
+ expect(
68
+ validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "x".repeat(65) }),
69
+ ).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe("ipc-server — onQueryPendingPermission handler (#2971)", () => {
74
+ const servers: IpcServer[] = [];
75
+
76
+ afterEach(async () => {
77
+ for (const s of servers) await s.close();
78
+ servers.length = 0;
79
+ });
80
+
81
+ it("fails CLOSED (pending:false) when no handler is wired — mixed-version-safe default", async () => {
82
+ const socketPath = tmpSocket();
83
+ const server = createIpcServer({
84
+ socketPath,
85
+ onClientRegistered: () => {},
86
+ onClientDisconnected: () => {},
87
+ onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
88
+ onSessionEvent: () => {},
89
+ onPermissionRequest: () => {},
90
+ onHeartbeat: () => {},
91
+ onScheduleRestart: () => {},
92
+ // onQueryPendingPermission intentionally omitted.
93
+ });
94
+ servers.push(server);
95
+ await wait(50);
96
+
97
+ const reply = await queryOnce(socketPath, "carrie", "corr-1");
98
+ expect(reply).toEqual({ type: "pending_permission_status", correlationId: "corr-1", pending: false });
99
+ });
100
+
101
+ it("invokes the wired handler with the query, and relays its reply", async () => {
102
+ const socketPath = tmpSocket();
103
+ let received: QueryPendingPermissionMessage | null = null;
104
+ const server = createIpcServer({
105
+ socketPath,
106
+ onClientRegistered: () => {},
107
+ onClientDisconnected: () => {},
108
+ onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
109
+ onSessionEvent: () => {},
110
+ onPermissionRequest: () => {},
111
+ onHeartbeat: () => {},
112
+ onScheduleRestart: () => {},
113
+ onQueryPendingPermission: (client: IpcClient, msg: QueryPendingPermissionMessage) => {
114
+ received = msg;
115
+ client.send({
116
+ type: "pending_permission_status",
117
+ correlationId: msg.correlationId,
118
+ pending: true,
119
+ requestId: "req-live-1",
120
+ });
121
+ },
122
+ });
123
+ servers.push(server);
124
+ await wait(50);
125
+
126
+ const reply = await queryOnce(socketPath, "carrie", "corr-2");
127
+ expect(received).toEqual({ type: "query_pending_permission", agentName: "carrie", correlationId: "corr-2" });
128
+ expect(reply).toEqual({
129
+ type: "pending_permission_status",
130
+ correlationId: "corr-2",
131
+ pending: true,
132
+ requestId: "req-live-1",
133
+ });
134
+ });
135
+
136
+ it("relays pending:false from a wired handler when nothing is live", async () => {
137
+ const socketPath = tmpSocket();
138
+ const server = createIpcServer({
139
+ socketPath,
140
+ onClientRegistered: () => {},
141
+ onClientDisconnected: () => {},
142
+ onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
143
+ onSessionEvent: () => {},
144
+ onPermissionRequest: () => {},
145
+ onHeartbeat: () => {},
146
+ onScheduleRestart: () => {},
147
+ onQueryPendingPermission: (client: IpcClient, msg: QueryPendingPermissionMessage) => {
148
+ client.send({ type: "pending_permission_status", correlationId: msg.correlationId, pending: false });
149
+ },
150
+ });
151
+ servers.push(server);
152
+ await wait(50);
153
+
154
+ const reply = await queryOnce(socketPath, "carrie", "corr-3");
155
+ expect(reply).toEqual({ type: "pending_permission_status", correlationId: "corr-3", pending: false });
156
+ });
157
+ });