switchroom 0.15.13 → 0.15.14
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 +312 -13
- package/dist/auth-broker/index.js +49 -3
- package/dist/cli/notion-write-pretool.mjs +49 -3
- package/dist/cli/switchroom.js +335 -107
- package/dist/host-control/main.js +49 -3
- package/dist/vault/approvals/kernel-server.js +50 -4
- package/dist/vault/broker/server.js +50 -4
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +30 -13
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +108 -9
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/gateway.ts +46 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
- package/telegram-plugin/gateway/ipc-server.ts +29 -0
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
- package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
- package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
|
@@ -363,6 +363,7 @@ import type {
|
|
|
363
363
|
PtyPartialForward,
|
|
364
364
|
InboundMessage,
|
|
365
365
|
InjectInboundMessage,
|
|
366
|
+
SendOutboundMessage,
|
|
366
367
|
QuotaWallDetectedMessage,
|
|
367
368
|
PermissionEvent,
|
|
368
369
|
} from './ipc-protocol.js'
|
|
@@ -6546,6 +6547,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6546
6547
|
process.stderr.write(
|
|
6547
6548
|
`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
|
|
6548
6549
|
)
|
|
6550
|
+
// #2307 Tier-1 observability: a climbing counter means the cron session is
|
|
6551
|
+
// down (registered then died, or never came up) — the Tier-1 saving is
|
|
6552
|
+
// lost for this fire. Surfaced via the unified runtime-metrics fan-out.
|
|
6553
|
+
emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: msg.agentName, prompt_key: promptKey })
|
|
6549
6554
|
}
|
|
6550
6555
|
// Status-silent (§2.4): a cron fire delivered to the CRON session must NOT
|
|
6551
6556
|
// set the MAIN agent's currentTurn. But a fire that LANDED on the main
|
|
@@ -6564,6 +6569,47 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6564
6569
|
}
|
|
6565
6570
|
},
|
|
6566
6571
|
|
|
6572
|
+
// #2307 Tier-0 action tier — a MODEL-FREE outbound post. The agent-scheduler
|
|
6573
|
+
// fires this for a `kind: action` `telegram-message`; the gateway posts the
|
|
6574
|
+
// (already-substituted) text to the agent's OWN chat with NO model: no
|
|
6575
|
+
// inject_inbound, no session wake, no currentTurn mutation. Two fences:
|
|
6576
|
+
// 1. agentName must match this gateway's own SWITCHROOM_AGENT_NAME.
|
|
6577
|
+
// 2. chatId must be an allowlisted chat for this agent (assertAllowedChat).
|
|
6578
|
+
// An action carries no chat target of its own — the scheduler supplies the
|
|
6579
|
+
// agent's own chat — so 2 is belt-and-braces against a malformed/foreign id.
|
|
6580
|
+
onSendOutbound(_client: IpcClient, msg: SendOutboundMessage) {
|
|
6581
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
6582
|
+
if (self && msg.agentName !== self) {
|
|
6583
|
+
process.stderr.write(
|
|
6584
|
+
`telegram gateway: send_outbound rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
6585
|
+
)
|
|
6586
|
+
return
|
|
6587
|
+
}
|
|
6588
|
+
try {
|
|
6589
|
+
assertAllowedChat(msg.chatId)
|
|
6590
|
+
} catch (err) {
|
|
6591
|
+
process.stderr.write(
|
|
6592
|
+
`telegram gateway: send_outbound rejected — ${(err as Error).message}\n`,
|
|
6593
|
+
)
|
|
6594
|
+
return
|
|
6595
|
+
}
|
|
6596
|
+
const threadId = msg.threadId
|
|
6597
|
+
const parseMode = msg.parseMode === 'text' ? undefined : 'HTML'
|
|
6598
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
|
|
6599
|
+
// General topic (thread 1) sends omit message_thread_id per the outbound convention.
|
|
6600
|
+
void swallowingApiCall(
|
|
6601
|
+
() =>
|
|
6602
|
+
bot.api.sendMessage(msg.chatId, msg.text, {
|
|
6603
|
+
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
6604
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
6605
|
+
}),
|
|
6606
|
+
{ chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
|
|
6607
|
+
)
|
|
6608
|
+
process.stderr.write(
|
|
6609
|
+
`telegram gateway: send_outbound agent=${msg.agentName} chat=${msg.chatId} thread=${threadId ?? '-'} len=${msg.text.length}\n`,
|
|
6610
|
+
)
|
|
6611
|
+
},
|
|
6612
|
+
|
|
6567
6613
|
// The wedge-watchdog detected claude's /rate-limit-options weekly-quota menu
|
|
6568
6614
|
// (a TUI wall that never produced a 429, so the inference-path auto-fallback
|
|
6569
6615
|
// never fired). Trigger the SAME fleet auto-fallback the 429 path uses,
|
|
@@ -413,6 +413,35 @@ export interface QuotaWallDetectedMessage {
|
|
|
413
413
|
resetAt?: number;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* #2307 (Tier-0 action tier) — a MODEL-FREE outbound post. Sent by the
|
|
418
|
+
* in-agent scheduler when a `kind: action` cron's `telegram-message` fires:
|
|
419
|
+
* the gateway posts `text` to the agent's OWN chat with no model involvement
|
|
420
|
+
* (NO `inject_inbound`, NO session wake, NO `currentTurn` mutation). This is
|
|
421
|
+
* the deliberate counterpart to `inject_inbound` — the one ClientToGateway
|
|
422
|
+
* verb that produces an outbound WITHOUT a turn.
|
|
423
|
+
*
|
|
424
|
+
* Trust model: same as `inject_inbound` — the socket is per-agent inside the
|
|
425
|
+
* container, only that-UID processes can connect. `agentName` is validated
|
|
426
|
+
* server-side, and the gateway FENCES `chatId` to the agent's own configured
|
|
427
|
+
* chat (DM allowlist / forum_chat_id) and rejects a foreign chat — an action
|
|
428
|
+
* can never post elsewhere (the action spec carries no chat target; the
|
|
429
|
+
* scheduler supplies the agent's own).
|
|
430
|
+
*/
|
|
431
|
+
export interface SendOutboundMessage {
|
|
432
|
+
type: "send_outbound";
|
|
433
|
+
/** Target agent — gateway verifies it matches its own SWITCHROOM_AGENT_NAME. */
|
|
434
|
+
agentName: string;
|
|
435
|
+
/** Agent's own chat id (fenced server-side against the agent's config). */
|
|
436
|
+
chatId: string;
|
|
437
|
+
/** Forum topic thread id (General/unset omitted; 1 is stripped on send). */
|
|
438
|
+
threadId?: number;
|
|
439
|
+
/** Message body — already substitution-resolved by the action engine. */
|
|
440
|
+
text: string;
|
|
441
|
+
/** Telegram parse mode. Defaults to HTML. */
|
|
442
|
+
parseMode?: "html" | "text";
|
|
443
|
+
}
|
|
444
|
+
|
|
416
445
|
export type ClientToGateway =
|
|
417
446
|
| RegisterMessage
|
|
418
447
|
| ToolCallMessage
|
|
@@ -428,4 +457,5 @@ export type ClientToGateway =
|
|
|
428
457
|
| RequestMs365ApprovalMessage
|
|
429
458
|
| RequestConfigApprovalMessage
|
|
430
459
|
| RequestConfigFinalizeMessage
|
|
431
|
-
| QuotaWallDetectedMessage
|
|
460
|
+
| QuotaWallDetectedMessage
|
|
461
|
+
| SendOutboundMessage;
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
GatewayToClient,
|
|
5
5
|
HeartbeatMessage,
|
|
6
6
|
InjectInboundMessage,
|
|
7
|
+
SendOutboundMessage,
|
|
7
8
|
QuotaWallDetectedMessage,
|
|
8
9
|
OperatorEventForward,
|
|
9
10
|
PermissionRequestForward,
|
|
@@ -45,6 +46,15 @@ export interface IpcServerOptions {
|
|
|
45
46
|
* inline scheduler simply ignore inject_inbound messages.
|
|
46
47
|
*/
|
|
47
48
|
onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
|
|
49
|
+
/**
|
|
50
|
+
* #2307 Tier-0 action tier — a model-free outbound post. Invoked when the
|
|
51
|
+
* agent-scheduler sibling fires a `kind: action` `telegram-message`. The
|
|
52
|
+
* handler is expected to post `msg.text` to `msg.chatId` (fenced to the
|
|
53
|
+
* agent's own chat) via the locked bot — with NO model, NO inject_inbound,
|
|
54
|
+
* NO session wake. Optional: gateways that don't run the inline scheduler
|
|
55
|
+
* ignore it.
|
|
56
|
+
*/
|
|
57
|
+
onSendOutbound?: (client: IpcClient, msg: SendOutboundMessage) => void;
|
|
48
58
|
/**
|
|
49
59
|
* The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
|
|
50
60
|
* weekly-quota menu (no 429 ever reached the gateway). Handler is expected to
|
|
@@ -246,6 +256,21 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
246
256
|
&& typeof inb.meta === "object"
|
|
247
257
|
&& inb.meta !== null;
|
|
248
258
|
}
|
|
259
|
+
case "send_outbound": {
|
|
260
|
+
// #2307 Tier-0 action tier — a model-free outbound post. Validate the
|
|
261
|
+
// wire shape; the gateway handler fences chatId to the agent's own chat.
|
|
262
|
+
if (typeof m.agentName !== "string"
|
|
263
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
264
|
+
if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
|
|
265
|
+
// text non-empty and bounded — Telegram caps a message at 4096 chars;
|
|
266
|
+
// reject over-long here (defense in depth against a malformed payload).
|
|
267
|
+
if (typeof m.text !== "string" || (m.text as string).length === 0
|
|
268
|
+
|| (m.text as string).length > 4096) return false;
|
|
269
|
+
if (m.threadId !== undefined
|
|
270
|
+
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
271
|
+
if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
249
274
|
case "quota_wall_detected": {
|
|
250
275
|
// wedge-watchdog detected the /rate-limit-options weekly-quota menu.
|
|
251
276
|
if (typeof m.agentName !== "string"
|
|
@@ -335,6 +360,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
335
360
|
onOperatorEvent,
|
|
336
361
|
onPtyPartial,
|
|
337
362
|
onInjectInbound,
|
|
363
|
+
onSendOutbound,
|
|
338
364
|
onQuotaWallDetected,
|
|
339
365
|
onRequestDriveApproval,
|
|
340
366
|
onRequestMs365Approval,
|
|
@@ -444,6 +470,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
444
470
|
case "inject_inbound":
|
|
445
471
|
if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
|
|
446
472
|
break;
|
|
473
|
+
case "send_outbound":
|
|
474
|
+
if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
|
|
475
|
+
break;
|
|
447
476
|
case "quota_wall_detected":
|
|
448
477
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
449
478
|
break;
|
|
@@ -137,6 +137,20 @@ export type RuntimeMetricEvent =
|
|
|
137
137
|
// executeReply scrub site. The two new sites close that gap.
|
|
138
138
|
site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* #2307 Tier-1: a cron fire routed to the `<agent>-cron` cheap session
|
|
142
|
+
* (meta.session=cron) fell back to the MAIN session because the cron bridge
|
|
143
|
+
* wasn't registered (wedged boot, crashed session, or hot-added cron with no
|
|
144
|
+
* live session yet). Each occurrence means the Tier-1 saving was NOT realised
|
|
145
|
+
* for that fire — a climbing counter is the runtime signal that a cron
|
|
146
|
+
* session is down (the doctor check catches a permanently-wedged one at boot;
|
|
147
|
+
* this catches a session that registered then died).
|
|
148
|
+
*/
|
|
149
|
+
| {
|
|
150
|
+
kind: 'cron_fell_back_to_main'
|
|
151
|
+
agent: string
|
|
152
|
+
prompt_key: string
|
|
153
|
+
}
|
|
140
154
|
|
|
141
155
|
/**
|
|
142
156
|
* The JSONL sink lives under the runtime state dir so it's per-agent
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2307 Tier-1 — the cron-session bridge writes its liveness file to a DISTINCT
|
|
3
|
+
* path so a live `<agent>-cron` bridge can't mask a dead MAIN bridge in the
|
|
4
|
+
* dashboard/doctor probe (RISK #2). The bridge is an IIFE (can't instantiate
|
|
5
|
+
* in-process), so this pins the wiring by source-read: the liveness file path
|
|
6
|
+
* honours `SWITCHROOM_BRIDGE_ALIVE_PATH`, falling back to the canonical
|
|
7
|
+
* STATE_DIR/.bridge-alive (unchanged for the main bridge).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
const bridgeSrc = readFileSync(resolve(__dirname, "..", "bridge", "bridge.ts"), "utf-8");
|
|
14
|
+
|
|
15
|
+
describe("bridge liveness path override (#2307 Tier-1)", () => {
|
|
16
|
+
it("honours SWITCHROOM_BRIDGE_ALIVE_PATH, falling back to STATE_DIR/.bridge-alive", () => {
|
|
17
|
+
expect(bridgeSrc).toMatch(
|
|
18
|
+
/livenessFilePath:\s*process\.env\.SWITCHROOM_BRIDGE_ALIVE_PATH\s*\?\?\s*join\(STATE_DIR,\s*["']\.bridge-alive["']\)/,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation contract for the `send_outbound` IPC verb (#2307 Tier-0 action
|
|
3
|
+
* tier) — the MODEL-FREE outbound the agent-scheduler sends for a `kind: action`
|
|
4
|
+
* telegram-message. A rogue process on the same UDS must not inject a malformed
|
|
5
|
+
* payload: agentName required + name-shaped, chatId + text required non-empty,
|
|
6
|
+
* threadId (optional) an integer, parseMode (optional) html|text.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
10
|
+
|
|
11
|
+
const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
|
|
12
|
+
|
|
13
|
+
describe("validateClientMessage — send_outbound", () => {
|
|
14
|
+
it("accepts a well-formed minimal message", () => {
|
|
15
|
+
expect(validateClientMessage(base)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts threadId + parseMode", () => {
|
|
19
|
+
expect(validateClientMessage({ ...base, threadId: 7, parseMode: "html" })).toBe(true);
|
|
20
|
+
expect(validateClientMessage({ ...base, threadId: 1, parseMode: "text" })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects a missing / non-string / malformed agentName", () => {
|
|
24
|
+
expect(validateClientMessage({ ...base, agentName: undefined })).toBe(false);
|
|
25
|
+
expect(validateClientMessage({ ...base, agentName: 123 })).toBe(false);
|
|
26
|
+
expect(validateClientMessage({ ...base, agentName: "" })).toBe(false);
|
|
27
|
+
expect(validateClientMessage({ ...base, agentName: "../etc" })).toBe(false);
|
|
28
|
+
expect(validateClientMessage({ ...base, agentName: "Clerk UPPER" })).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects a missing / empty chatId", () => {
|
|
32
|
+
expect(validateClientMessage({ ...base, chatId: undefined })).toBe(false);
|
|
33
|
+
expect(validateClientMessage({ ...base, chatId: "" })).toBe(false);
|
|
34
|
+
expect(validateClientMessage({ ...base, chatId: 123 })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects a missing / empty / over-long text", () => {
|
|
38
|
+
expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
|
|
39
|
+
expect(validateClientMessage({ ...base, text: "" })).toBe(false);
|
|
40
|
+
expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
|
|
41
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
|
|
42
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects a non-integer threadId", () => {
|
|
46
|
+
expect(validateClientMessage({ ...base, threadId: 1.5 })).toBe(false);
|
|
47
|
+
expect(validateClientMessage({ ...base, threadId: "1" })).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects an unknown parseMode", () => {
|
|
51
|
+
expect(validateClientMessage({ ...base, parseMode: "markdown" })).toBe(false);
|
|
52
|
+
expect(validateClientMessage({ ...base, parseMode: 1 })).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -82,6 +82,15 @@ describe('runtime-metrics — JSONL sink', () => {
|
|
|
82
82
|
expect(parsed.ended_via).toBe('reply')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('cron_fell_back_to_main carries agent + prompt_key (#2307 Tier-1)', () => {
|
|
86
|
+
emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: 'clerk', prompt_key: 'abc123' })
|
|
87
|
+
const parsed = JSON.parse(readFileSync(metricsPath, 'utf-8').trim())
|
|
88
|
+
expect(parsed.kind).toBe('cron_fell_back_to_main')
|
|
89
|
+
expect(parsed.agent).toBe('clerk')
|
|
90
|
+
expect(parsed.prompt_key).toBe('abc123')
|
|
91
|
+
expect(typeof parsed.ts).toBe('number')
|
|
92
|
+
})
|
|
93
|
+
|
|
85
94
|
it('appends — does not overwrite — across calls', () => {
|
|
86
95
|
for (let i = 0; i < 5; i++) {
|
|
87
96
|
emitRuntimeMetric({
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural wiring guard for the model-free `send_outbound` verb (#2307).
|
|
3
|
+
*
|
|
4
|
+
* The gateway handler lives in the createIpcServer({...}) IIFE block, which
|
|
5
|
+
* can't be imported in a unit test (same constraint as the inject_inbound /
|
|
6
|
+
* linear-activity wiring tests), so the load-bearing invariants are pinned by
|
|
7
|
+
* reading the source:
|
|
8
|
+
* 1. ipc-server routes `send_outbound` → onSendOutbound.
|
|
9
|
+
* 2. the gateway handler fences agent (agentName === self) AND chat
|
|
10
|
+
* (assertAllowedChat) before sending.
|
|
11
|
+
* 3. it is MODEL-FREE: never markClaudeBusy / currentTurn / inject — a
|
|
12
|
+
* send_outbound must not wake a turn (the whole point of Tier-0).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
|
|
18
|
+
const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
|
|
19
|
+
|
|
20
|
+
describe("send_outbound — ipc-server routing", () => {
|
|
21
|
+
it("validates the verb and dispatches to onSendOutbound", () => {
|
|
22
|
+
expect(ipcServer).toMatch(/case "send_outbound":/);
|
|
23
|
+
expect(ipcServer).toMatch(/onSendOutbound\(client, msg as SendOutboundMessage\)/);
|
|
24
|
+
// the validator has a dedicated arm.
|
|
25
|
+
expect(ipcServer).toMatch(/case "send_outbound": \{/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("send_outbound — gateway handler invariants", () => {
|
|
30
|
+
// Isolate the handler body for the assertions below.
|
|
31
|
+
const start = gw.indexOf("onSendOutbound(_client: IpcClient, msg: SendOutboundMessage)");
|
|
32
|
+
// Bound the slice at the START of the NEXT handler so the whole onSendOutbound
|
|
33
|
+
// body is covered regardless of how it grows (no fixed char window to outgrow),
|
|
34
|
+
// without bleeding into a sibling handler.
|
|
35
|
+
const next = gw.indexOf("onQuotaWallDetected(", start);
|
|
36
|
+
const handler = gw.slice(start, next > start ? next : start + 2600);
|
|
37
|
+
|
|
38
|
+
it("the handler exists", () => {
|
|
39
|
+
expect(start).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("fences the agent (agentName must match this gateway's own)", () => {
|
|
43
|
+
expect(handler).toMatch(/msg\.agentName !== self/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fences the chat to the agent's own allowlist (assertAllowedChat)", () => {
|
|
47
|
+
expect(handler).toMatch(/assertAllowedChat\(msg\.chatId\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("is MODEL-FREE — never wakes a turn (no markClaudeBusy / currentTurn / inject)", () => {
|
|
51
|
+
expect(handler).not.toMatch(/markClaudeBusy/);
|
|
52
|
+
expect(handler).not.toMatch(/currentTurn/);
|
|
53
|
+
expect(handler).not.toMatch(/sendToAgent/);
|
|
54
|
+
expect(handler).not.toMatch(/inject/i);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sends via the wrapped retry path (not a raw bot.api outside swallowingApiCall)", () => {
|
|
58
|
+
expect(handler).toMatch(/swallowingApiCall\(/);
|
|
59
|
+
expect(handler).toMatch(/bot\.api\.sendMessage\(msg\.chatId, msg\.text/);
|
|
60
|
+
// General topic (thread 1) is stripped on send, per the outbound convention.
|
|
61
|
+
expect(handler).toMatch(/threadId !== 1/);
|
|
62
|
+
});
|
|
63
|
+
});
|