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.
- package/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- 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 +
|
|
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 +
|
|
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 +
|
|
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
|
-
//
|
|
354
|
-
//
|
|
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
|
|
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 (
|
|
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("
|
|
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("
|
|
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
|
|
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("
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|