fix: require explicit system event session keys

This commit is contained in:
Peter Steinberger
2026-01-09 21:58:55 +01:00
parent cb2a365594
commit 42a0089b3b
14 changed files with 123 additions and 53 deletions

View File

@@ -51,6 +51,7 @@
- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73 - WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
- Doctor: run legacy state migrations in non-interactive mode without prompts. - Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino - Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Cron: enqueue main-session system events under the resolved main session key. (#510)
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos

View File

@@ -14,6 +14,8 @@ import {
import { drainSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents } from "../infra/system-events.js";
import { getReplyFromConfig } from "./reply.js"; import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main";
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(), runEmbeddedPiAgent: vi.fn(),
@@ -1390,7 +1392,7 @@ describe("directive behavior", () => {
it("queues a system event when switching models", async () => { it("queues a system event when switching models", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
drainSystemEvents(); drainSystemEvents(MAIN_SESSION_KEY);
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
@@ -1412,7 +1414,7 @@ describe("directive behavior", () => {
}, },
); );
const events = drainSystemEvents(); const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events).toContain( expect(events).toContain(
"Model switched to Opus (anthropic/claude-opus-4-5).", "Model switched to Opus (anthropic/claude-opus-4-5).",
); );

View File

@@ -111,7 +111,7 @@ export async function buildStatusReply(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
command: CommandContext; command: CommandContext;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionKey?: string; sessionKey: string;
sessionScope?: SessionScope; sessionScope?: SessionScope;
provider: string; provider: string;
model: string; model: string;
@@ -390,7 +390,7 @@ export async function handleCommands(params: {
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey?: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope?: SessionScope; sessionScope?: SessionScope;
workspaceDir: string; workspaceDir: string;
@@ -815,7 +815,7 @@ export async function handleCommands(params: {
const line = reason const line = reason
? `${compactLabel}: ${reason}${contextSummary}` ? `${compactLabel}: ${reason}${contextSummary}`
: `${compactLabel}${contextSummary}`; : `${compactLabel}${contextSummary}`;
enqueueSystemEvent(line); enqueueSystemEvent(line, { sessionKey });
return { shouldContinue: false, reply: { text: `⚙️ ${line}` } }; return { shouldContinue: false, reply: { text: `⚙️ ${line}` } };
} }

View File

@@ -447,7 +447,7 @@ export async function handleDirectiveOnly(params: {
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey?: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
elevatedAllowed: boolean; elevatedAllowed: boolean;
@@ -836,6 +836,7 @@ export async function handleDirectiveOnly(params: {
enqueueSystemEvent( enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias), formatModelSwitchEvent(nextLabel, modelSelection.alias),
{ {
sessionKey,
contextKey: `model:${nextLabel}`, contextKey: `model:${nextLabel}`,
}, },
); );
@@ -1103,6 +1104,7 @@ export async function persistInlineDirectives(params: {
enqueueSystemEvent( enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, resolved.alias), formatModelSwitchEvent(nextLabel, resolved.alias),
{ {
sessionKey,
contextKey: `model:${nextLabel}`, contextKey: `model:${nextLabel}`,
}, },
); );

View File

@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
systemSent: true, systemSent: true,
}, },
}), }),
resolveMainSessionKey: vi.fn().mockReturnValue("agent:main:main"),
resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"), resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"),
webAuthExists: vi.fn().mockResolvedValue(true), webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(5000), getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
@@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("../config/sessions.js", () => ({ vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore, loadSessionStore: mocks.loadSessionStore,
resolveMainSessionKey: mocks.resolveMainSessionKey,
resolveStorePath: mocks.resolveStorePath, resolveStorePath: mocks.resolveStorePath,
})); }));
vi.mock("../web/session.js", () => ({ vi.mock("../web/session.js", () => ({

View File

@@ -9,6 +9,7 @@ import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js"; import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { import {
loadSessionStore, loadSessionStore,
resolveMainSessionKey,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
} from "../config/sessions.js"; } from "../config/sessions.js";
@@ -77,7 +78,8 @@ export async function getStatusSummary(): Promise<StatusSummary> {
colorize: true, colorize: true,
includeAllowFrom: true, includeAllowFrom: true,
}); });
const queuedSystemEvents = peekSystemEvents(); const mainSessionKey = resolveMainSessionKey(cfg);
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg, cfg,

View File

@@ -1,3 +1,5 @@
import { loadConfig } from "../../config/config.js";
import { resolveMainSessionKey } from "../../config/sessions.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js"; import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js"; import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { import {
@@ -45,6 +47,7 @@ export const systemHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const sessionKey = resolveMainSessionKey(loadConfig());
const instanceId = const instanceId =
typeof params.instanceId === "string" ? params.instanceId : undefined; typeof params.instanceId === "string" ? params.instanceId : undefined;
const host = typeof params.host === "string" ? params.host : undefined; const host = typeof params.host === "string" ? params.host : undefined;
@@ -107,7 +110,10 @@ export const systemHandlers: GatewayRequestHandlers = {
modeChanged || modeChanged ||
reasonChanged; reasonChanged;
if (hasChanges) { if (hasChanges) {
const contextChanged = isSystemEventContextChanged(presenceUpdate.key); const contextChanged = isSystemEventContextChanged(
sessionKey,
presenceUpdate.key,
);
const parts: string[] = []; const parts: string[] = [];
if (contextChanged || hostChanged || ipChanged) { if (contextChanged || hostChanged || ipChanged) {
const hostLabel = next.host?.trim() || "Unknown"; const hostLabel = next.host?.trim() || "Unknown";
@@ -126,12 +132,13 @@ export const systemHandlers: GatewayRequestHandlers = {
const deltaText = parts.join(" · "); const deltaText = parts.join(" · ");
if (deltaText) { if (deltaText) {
enqueueSystemEvent(deltaText, { enqueueSystemEvent(deltaText, {
sessionKey,
contextKey: presenceUpdate.key, contextKey: presenceUpdate.key,
}); });
} }
} }
} else { } else {
enqueueSystemEvent(text); enqueueSystemEvent(text, { sessionKey });
} }
const nextPresenceVersion = context.incrementPresenceVersion(); const nextPresenceVersion = context.incrementPresenceVersion();
context.broadcast( context.broadcast(

View File

@@ -8,6 +8,7 @@ import {
rpcReq, rpcReq,
startServerWithClient, startServerWithClient,
testState, testState,
waitForSystemEvent,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks(); installGatewayTestHooks();
@@ -55,6 +56,48 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test("enqueues main cron system events to the resolved main session key", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "route test",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "cron route check" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" });
expect(runRes.ok).toBe(true);
const events = await waitForSystemEvent();
expect(events.some((event) => event.includes("cron route check"))).toBe(
true,
);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
});
test("normalizes wrapped cron.add payloads", async () => { test("normalizes wrapped cron.add payloads", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronStorePath = path.join(dir, "cron", "jobs.json");

View File

@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { import {
cronIsolatedRun, cronIsolatedRun,
@@ -11,6 +13,8 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
const resolveMainKey = () => resolveMainSessionKey(loadConfig());
describe("gateway server hooks", () => { describe("gateway server hooks", () => {
test("hooks wake requires auth", async () => { test("hooks wake requires auth", async () => {
testState.hooksConfig = { enabled: true, token: "hook-secret" }; testState.hooksConfig = { enabled: true, token: "hook-secret" };
@@ -40,7 +44,7 @@ describe("gateway server hooks", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const events = await waitForSystemEvent(); const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Ping"))).toBe(true); expect(events.some((e) => e.includes("Ping"))).toBe(true);
drainSystemEvents(); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });
@@ -63,7 +67,7 @@ describe("gateway server hooks", () => {
expect(res.status).toBe(202); expect(res.status).toBe(202);
const events = await waitForSystemEvent(); const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true); expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true);
drainSystemEvents(); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });
@@ -94,7 +98,7 @@ describe("gateway server hooks", () => {
job?: { payload?: { model?: string } }; job?: { payload?: { model?: string } };
}; };
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini");
drainSystemEvents(); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });
@@ -113,7 +117,7 @@ describe("gateway server hooks", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const events = await waitForSystemEvent(); const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Query auth"))).toBe(true); expect(events.some((e) => e.includes("Query auth"))).toBe(true);
drainSystemEvents(); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });
@@ -130,7 +134,7 @@ describe("gateway server hooks", () => {
body: JSON.stringify({ message: "Nope", provider: "sms" }), body: JSON.stringify({ message: "Nope", provider: "sms" }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(peekSystemEvents().length).toBe(0); expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
await server.close(); await server.close();
}); });
@@ -149,7 +153,7 @@ describe("gateway server hooks", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const events = await waitForSystemEvent(); const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Header auth"))).toBe(true); expect(events.some((e) => e.includes("Header auth"))).toBe(true);
drainSystemEvents(); drainSystemEvents(resolveMainKey());
await server.close(); await server.close();
}); });

View File

@@ -488,7 +488,8 @@ export async function startGatewayServer(
text: string; text: string;
mode: "now" | "next-heartbeat"; mode: "now" | "next-heartbeat";
}) => { }) => {
enqueueSystemEvent(value.text); const sessionKey = resolveMainSessionKey(loadConfig());
enqueueSystemEvent(value.text, { sessionKey });
if (value.mode === "now") { if (value.mode === "now") {
requestHeartbeatNow({ reason: "hook:wake" }); requestHeartbeatNow({ reason: "hook:wake" });
} }
@@ -509,6 +510,7 @@ export async function startGatewayServer(
const sessionKey = value.sessionKey.trim() const sessionKey = value.sessionKey.trim()
? value.sessionKey.trim() ? value.sessionKey.trim()
: `hook:${randomUUID()}`; : `hook:${randomUUID()}`;
const mainSessionKey = resolveMainSessionKey(loadConfig());
const jobId = randomUUID(); const jobId = randomUUID();
const now = Date.now(); const now = Date.now();
const job: CronJob = { const job: CronJob = {
@@ -551,13 +553,17 @@ export async function startGatewayServer(
result.status === "ok" result.status === "ok"
? `Hook ${value.name}` ? `Hook ${value.name}`
: `Hook ${value.name} (${result.status})`; : `Hook ${value.name} (${result.status})`;
enqueueSystemEvent(`${prefix}: ${summary}`.trim()); enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
sessionKey: mainSessionKey,
});
if (value.wakeMode === "now") { if (value.wakeMode === "now") {
requestHeartbeatNow({ reason: `hook:${jobId}` }); requestHeartbeatNow({ reason: `hook:${jobId}` });
} }
} catch (err) { } catch (err) {
logHooks.warn(`hook agent failed: ${String(err)}`); logHooks.warn(`hook agent failed: ${String(err)}`);
enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`); enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`, {
sessionKey: mainSessionKey,
});
if (value.wakeMode === "now") { if (value.wakeMode === "now") {
requestHeartbeatNow({ reason: `hook:${jobId}:error` }); requestHeartbeatNow({ reason: `hook:${jobId}:error` });
} }
@@ -1822,7 +1828,8 @@ export async function startGatewayServer(
const summary = summarizeRestartSentinel(payload); const summary = summarizeRestartSentinel(payload);
if (!sessionKey) { if (!sessionKey) {
enqueueSystemEvent(message); const mainSessionKey = resolveMainSessionKey(loadConfig());
enqueueSystemEvent(message, { sessionKey: mainSessionKey });
return; return;
} }
@@ -1836,7 +1843,7 @@ export async function startGatewayServer(
const provider = lastProvider ?? parsedTarget?.provider; const provider = lastProvider ?? parsedTarget?.provider;
const to = lastTo || parsedTarget?.to; const to = lastTo || parsedTarget?.to;
if (!provider || !to) { if (!provider || !to) {
enqueueSystemEvent(message); enqueueSystemEvent(message, { sessionKey });
return; return;
} }
@@ -1853,7 +1860,7 @@ export async function startGatewayServer(
allowFrom: cfg.whatsapp?.allowFrom ?? [], allowFrom: cfg.whatsapp?.allowFrom ?? [],
}); });
if (!resolved.ok) { if (!resolved.ok) {
enqueueSystemEvent(message); enqueueSystemEvent(message, { sessionKey });
return; return;
} }
@@ -1872,7 +1879,7 @@ export async function startGatewayServer(
deps, deps,
); );
} catch (err) { } catch (err) {
enqueueSystemEvent(`${summary}\n${String(err)}`); enqueueSystemEvent(`${summary}\n${String(err)}`, { sessionKey });
} }
}; };

View File

@@ -4,6 +4,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest"; import { afterEach, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@@ -373,7 +375,7 @@ export function installGatewayTestHooks() {
embeddedRunMock.abortCalls = []; embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = []; embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear(); embeddedRunMock.waitResults.clear();
drainSystemEvents(); drainSystemEvents(resolveMainSessionKey(loadConfig()));
resetAgentRunContextForTest(); resetAgentRunContextForTest();
const mod = await import("./server.js"); const mod = await import("./server.js");
mod.__resetModelCatalogCacheForTest(); mod.__resetModelCatalogCacheForTest();
@@ -553,9 +555,10 @@ export async function rpcReq<T = unknown>(
} }
export async function waitForSystemEvent(timeoutMs = 2000) { export async function waitForSystemEvent(timeoutMs = 2000) {
const sessionKey = resolveMainSessionKey(loadConfig());
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const events = peekSystemEvents(); const events = peekSystemEvents(sessionKey);
if (events.length > 0) return events; if (events.length > 0) return events;
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
} }

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { prependSystemEvents } from "../auto-reply/reply/session-updates.js"; import { prependSystemEvents } from "../auto-reply/reply/session-updates.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { import {
enqueueSystemEvent, enqueueSystemEvent,
peekSystemEvents, peekSystemEvents,
@@ -9,6 +10,7 @@ import {
} from "./system-events.js"; } from "./system-events.js";
const cfg = {} as unknown as ClawdbotConfig; const cfg = {} as unknown as ClawdbotConfig;
const mainKey = resolveMainSessionKey(cfg);
describe("system events (session routing)", () => { describe("system events (session routing)", () => {
beforeEach(() => { beforeEach(() => {
@@ -21,14 +23,14 @@ describe("system events (session routing)", () => {
contextKey: "discord:reaction:added:msg:user:✅", contextKey: "discord:reaction:added:msg:user:✅",
}); });
expect(peekSystemEvents()).toEqual([]); expect(peekSystemEvents(mainKey)).toEqual([]);
expect(peekSystemEvents("discord:group:123")).toEqual([ expect(peekSystemEvents("discord:group:123")).toEqual([
"Discord reaction added: ✅", "Discord reaction added: ✅",
]); ]);
const main = await prependSystemEvents({ const main = await prependSystemEvents({
cfg, cfg,
sessionKey: "main", sessionKey: mainKey,
isMainSession: true, isMainSession: true,
isNewSession: false, isNewSession: false,
prefixedBodyBase: "hello", prefixedBodyBase: "hello",
@@ -49,16 +51,9 @@ describe("system events (session routing)", () => {
expect(peekSystemEvents("discord:group:123")).toEqual([]); expect(peekSystemEvents("discord:group:123")).toEqual([]);
}); });
it("defaults system events to main", async () => { it("requires an explicit session key", () => {
enqueueSystemEvent("Node: Mac Studio"); expect(() =>
enqueueSystemEvent("Node: Mac Studio", { sessionKey: " " }),
const main = await prependSystemEvents({ ).toThrow("sessionKey");
cfg,
sessionKey: "main",
isMainSession: true,
isNewSession: false,
prefixedBodyBase: "ping",
});
expect(main).toBe("System: Node: Mac Studio\n\nping");
}); });
}); });

View File

@@ -1,11 +1,9 @@
// Lightweight in-memory queue for human-readable system events that should be // Lightweight in-memory queue for human-readable system events that should be
// prefixed to the next prompt. We intentionally avoid persistence to keep // prefixed to the next prompt. We intentionally avoid persistence to keep
// events ephemeral. Events are session-scoped; callers that don't specify a // events ephemeral. Events are session-scoped and require an explicit key.
// session key default to "main".
type SystemEvent = { text: string; ts: number }; type SystemEvent = { text: string; ts: number };
const DEFAULT_SESSION_KEY = "main";
const MAX_EVENTS = 20; const MAX_EVENTS = 20;
type SessionQueue = { type SessionQueue = {
@@ -17,13 +15,16 @@ type SessionQueue = {
const queues = new Map<string, SessionQueue>(); const queues = new Map<string, SessionQueue>();
type SystemEventOptions = { type SystemEventOptions = {
sessionKey: string;
contextKey?: string | null; contextKey?: string | null;
sessionKey?: string | null;
}; };
function normalizeSessionKey(key?: string | null): string { function requireSessionKey(key?: string | null): string {
const trimmed = typeof key === "string" ? key.trim() : ""; const trimmed = typeof key === "string" ? key.trim() : "";
return trimmed || DEFAULT_SESSION_KEY; if (!trimmed) {
throw new Error("system events require a sessionKey");
}
return trimmed;
} }
function normalizeContextKey(key?: string | null): string | null { function normalizeContextKey(key?: string | null): string | null {
@@ -34,17 +35,17 @@ function normalizeContextKey(key?: string | null): string | null {
} }
export function isSystemEventContextChanged( export function isSystemEventContextChanged(
sessionKey: string,
contextKey?: string | null, contextKey?: string | null,
sessionKey?: string | null,
): boolean { ): boolean {
const key = normalizeSessionKey(sessionKey); const key = requireSessionKey(sessionKey);
const existing = queues.get(key); const existing = queues.get(key);
const normalized = normalizeContextKey(contextKey); const normalized = normalizeContextKey(contextKey);
return normalized !== (existing?.lastContextKey ?? null); return normalized !== (existing?.lastContextKey ?? null);
} }
export function enqueueSystemEvent(text: string, options?: SystemEventOptions) { export function enqueueSystemEvent(text: string, options: SystemEventOptions) {
const key = normalizeSessionKey(options?.sessionKey); const key = requireSessionKey(options?.sessionKey);
const entry = const entry =
queues.get(key) ?? queues.get(key) ??
(() => { (() => {
@@ -65,8 +66,8 @@ export function enqueueSystemEvent(text: string, options?: SystemEventOptions) {
if (entry.queue.length > MAX_EVENTS) entry.queue.shift(); if (entry.queue.length > MAX_EVENTS) entry.queue.shift();
} }
export function drainSystemEvents(sessionKey?: string | null): string[] { export function drainSystemEvents(sessionKey: string): string[] {
const key = normalizeSessionKey(sessionKey); const key = requireSessionKey(sessionKey);
const entry = queues.get(key); const entry = queues.get(key);
if (!entry || entry.queue.length === 0) return []; if (!entry || entry.queue.length === 0) return [];
const out = entry.queue.map((e) => e.text); const out = entry.queue.map((e) => e.text);
@@ -77,13 +78,13 @@ export function drainSystemEvents(sessionKey?: string | null): string[] {
return out; return out;
} }
export function peekSystemEvents(sessionKey?: string | null): string[] { export function peekSystemEvents(sessionKey: string): string[] {
const key = normalizeSessionKey(sessionKey); const key = requireSessionKey(sessionKey);
return queues.get(key)?.queue.map((e) => e.text) ?? []; return queues.get(key)?.queue.map((e) => e.text) ?? [];
} }
export function hasSystemEvents(sessionKey?: string | null) { export function hasSystemEvents(sessionKey: string) {
const key = normalizeSessionKey(sessionKey); const key = requireSessionKey(sessionKey);
return (queues.get(key)?.queue.length ?? 0) > 0; return (queues.get(key)?.queue.length ?? 0) > 0;
} }

View File

@@ -1781,6 +1781,7 @@ export async function monitorWebProvider(
enqueueSystemEvent( enqueueSystemEvent(
`WhatsApp gateway disconnected (status ${statusCode ?? "unknown"})`, `WhatsApp gateway disconnected (status ${statusCode ?? "unknown"})`,
{ sessionKey: connectRoute.sessionKey },
); );
if (loggedOut) { if (loggedOut) {