fix(gateway): stream chat events for agent runs
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
|
||||
### Fixes
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
||||
- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages.
|
||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||
import { triggerClawdisRestart } from "../infra/restart.js";
|
||||
import {
|
||||
@@ -1196,6 +1197,9 @@ export async function getReplyFromConfig(
|
||||
await startTypingLoop();
|
||||
}
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../infra/agent-events.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
@@ -204,6 +207,10 @@ export async function agentCommand(
|
||||
} = sessionResolution;
|
||||
let sessionEntry = resolvedSessionEntry;
|
||||
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(sessionId, { sessionKey });
|
||||
}
|
||||
|
||||
const resolvedThinkLevel =
|
||||
thinkOnce ??
|
||||
thinkOverride ??
|
||||
@@ -413,12 +420,15 @@ export async function agentCommand(
|
||||
const payloads = result.payloads ?? [];
|
||||
const deliver = opts.deliver === true;
|
||||
const bestEffortDeliver = opts.bestEffortDeliver === true;
|
||||
const deliveryProvider = (opts.provider ?? "whatsapp").toLowerCase();
|
||||
const deliveryProviderRaw = (opts.provider ?? "whatsapp").toLowerCase();
|
||||
const deliveryProvider =
|
||||
deliveryProviderRaw === "imsg" ? "imessage" : deliveryProviderRaw;
|
||||
|
||||
const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0];
|
||||
const telegramTarget = opts.to?.trim() || undefined;
|
||||
const discordTarget = opts.to?.trim() || undefined;
|
||||
const signalTarget = opts.to?.trim() || undefined;
|
||||
const imessageTarget = opts.to?.trim() || undefined;
|
||||
|
||||
const logDeliveryError = (err: unknown) => {
|
||||
const deliveryTarget =
|
||||
@@ -428,8 +438,10 @@ export async function agentCommand(
|
||||
? whatsappTarget
|
||||
: deliveryProvider === "discord"
|
||||
? discordTarget
|
||||
: deliveryProvider === "signal"
|
||||
? signalTarget
|
||||
: deliveryProvider === "signal"
|
||||
? signalTarget
|
||||
: deliveryProvider === "imessage"
|
||||
? imessageTarget
|
||||
: undefined;
|
||||
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
|
||||
runtime.error?.(message);
|
||||
@@ -463,6 +475,13 @@ export async function agentCommand(
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
if (deliveryProvider === "imessage" && !imessageTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to iMessage requires --to <handle|chat_id:ID>",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
if (deliveryProvider === "webchat") {
|
||||
const err = new Error(
|
||||
"Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.",
|
||||
@@ -475,6 +494,7 @@ export async function agentCommand(
|
||||
deliveryProvider !== "telegram" &&
|
||||
deliveryProvider !== "discord" &&
|
||||
deliveryProvider !== "signal" &&
|
||||
deliveryProvider !== "imessage" &&
|
||||
deliveryProvider !== "webchat"
|
||||
) {
|
||||
const err = new Error(`Unknown provider: ${deliveryProvider}`);
|
||||
@@ -621,5 +641,38 @@ export async function agentCommand(
|
||||
logDeliveryError(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryProvider === "imessage" && imessageTarget) {
|
||||
try {
|
||||
if (media.length === 0) {
|
||||
for (const chunk of chunkText(text, 4000)) {
|
||||
await deps.sendMessageIMessage(imessageTarget, chunk, {
|
||||
maxBytes: cfg.imessage?.mediaMaxMb
|
||||
? cfg.imessage.mediaMaxMb * 1024 * 1024
|
||||
: cfg.agent?.mediaMaxMb
|
||||
? cfg.agent.mediaMaxMb * 1024 * 1024
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of media) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await deps.sendMessageIMessage(imessageTarget, caption, {
|
||||
mediaUrl: url,
|
||||
maxBytes: cfg.imessage?.mediaMaxMb
|
||||
? cfg.imessage.mediaMaxMb * 1024 * 1024
|
||||
: cfg.agent?.mediaMaxMb
|
||||
? cfg.agent.mediaMaxMb * 1024 * 1024
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
@@ -54,7 +55,13 @@ function pickSummaryFromPayloads(
|
||||
function resolveDeliveryTarget(
|
||||
cfg: ClawdisConfig,
|
||||
jobPayload: {
|
||||
channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal";
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
},
|
||||
) {
|
||||
@@ -81,7 +88,8 @@ function resolveDeliveryTarget(
|
||||
requestedChannel === "whatsapp" ||
|
||||
requestedChannel === "telegram" ||
|
||||
requestedChannel === "discord" ||
|
||||
requestedChannel === "signal"
|
||||
requestedChannel === "signal" ||
|
||||
requestedChannel === "imessage"
|
||||
) {
|
||||
return requestedChannel;
|
||||
}
|
||||
@@ -244,6 +252,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const sessionFile = resolveSessionTranscriptPath(
|
||||
cronSession.sessionEntry.sessionId,
|
||||
);
|
||||
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -457,6 +468,44 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
} else if (resolvedDelivery.channel === "imessage") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver)
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
error: "Cron delivery to iMessage requires a recipient.",
|
||||
};
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: "Delivery skipped (no iMessage recipient).",
|
||||
};
|
||||
}
|
||||
const to = resolvedDelivery.to;
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(payload.text ?? "", 4000)) {
|
||||
await params.deps.sendMessageIMessage(to, chunk);
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageIMessage(to, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
STATE_DIR_CLAWDIS,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
resetAgentRunContextForTest,
|
||||
} from "../infra/agent-events.js";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
@@ -277,6 +281,7 @@ beforeEach(async () => {
|
||||
testCanvasHostPort = undefined;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
resetAgentRunContextForTest();
|
||||
__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
@@ -3621,6 +3626,73 @@ describe("gateway server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent events stream to webchat clients when run context is registered", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testSessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testSessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "webchat",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "webchat",
|
||||
},
|
||||
});
|
||||
|
||||
registerAgentRunContext("run-auto-1", { sessionKey: "main" });
|
||||
|
||||
const finalChatP = onceMessage<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>(
|
||||
ws,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "chat") return false;
|
||||
const payload = o.payload as { state?: unknown; runId?: unknown } | undefined;
|
||||
return payload?.state === "final" && payload.runId === "run-auto-1";
|
||||
},
|
||||
8000,
|
||||
);
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-auto-1",
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "run-auto-1",
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
const evt = await finalChatP;
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
expect(payload.runId).toBe("run-auto-1");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testSessionStorePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -74,8 +74,18 @@ import {
|
||||
sendMessageDiscord,
|
||||
} from "../discord/index.js";
|
||||
import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
|
||||
import {
|
||||
monitorIMessageProvider,
|
||||
sendMessageIMessage,
|
||||
} from "../imessage/index.js";
|
||||
import { probeIMessage, type IMessageProbe } from "../imessage/probe.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
clearAgentRunContext,
|
||||
getAgentRunContext,
|
||||
onAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../infra/agent-events.js";
|
||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
||||
@@ -287,11 +297,13 @@ const logWhatsApp = logProviders.child("whatsapp");
|
||||
const logTelegram = logProviders.child("telegram");
|
||||
const logDiscord = logProviders.child("discord");
|
||||
const logSignal = logProviders.child("signal");
|
||||
const logIMessage = logProviders.child("imessage");
|
||||
const canvasRuntime = runtimeForLogger(logCanvas);
|
||||
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
|
||||
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
|
||||
const discordRuntimeEnv = runtimeForLogger(logDiscord);
|
||||
const signalRuntimeEnv = runtimeForLogger(logSignal);
|
||||
const imessageRuntimeEnv = runtimeForLogger(logIMessage);
|
||||
|
||||
function resolveBonjourCliPath(): string | undefined {
|
||||
const envPath = process.env.CLAWDIS_CLI_PATH?.trim();
|
||||
@@ -1345,7 +1357,13 @@ export async function startGatewayServer(
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
channel: "last" | "whatsapp" | "telegram" | "discord" | "signal";
|
||||
channel:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
@@ -1371,15 +1389,19 @@ export async function startGatewayServer(
|
||||
channelRaw === "telegram" ||
|
||||
channelRaw === "discord" ||
|
||||
channelRaw === "signal" ||
|
||||
channelRaw === "imessage" ||
|
||||
channelRaw === "last"
|
||||
? channelRaw
|
||||
: channelRaw === "imsg"
|
||||
? "imessage"
|
||||
: channelRaw === undefined
|
||||
? "last"
|
||||
: null;
|
||||
if (channel === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "channel must be last|whatsapp|telegram|discord|signal",
|
||||
error:
|
||||
"channel must be last|whatsapp|telegram|discord|signal|imessage",
|
||||
};
|
||||
}
|
||||
const toRaw = payload.to;
|
||||
@@ -1430,7 +1452,13 @@ export async function startGatewayServer(
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
channel: "last" | "whatsapp" | "telegram" | "discord" | "signal";
|
||||
channel:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
@@ -1714,10 +1742,12 @@ export async function startGatewayServer(
|
||||
let telegramAbort: AbortController | null = null;
|
||||
let discordAbort: AbortController | null = null;
|
||||
let signalAbort: AbortController | null = null;
|
||||
let imessageAbort: AbortController | null = null;
|
||||
let whatsappTask: Promise<unknown> | null = null;
|
||||
let telegramTask: Promise<unknown> | null = null;
|
||||
let discordTask: Promise<unknown> | null = null;
|
||||
let signalTask: Promise<unknown> | null = null;
|
||||
let imessageTask: Promise<unknown> | null = null;
|
||||
let whatsappRuntime: WebProviderStatus = {
|
||||
running: false,
|
||||
connected: false,
|
||||
@@ -1765,12 +1795,27 @@ export async function startGatewayServer(
|
||||
lastError: null,
|
||||
baseUrl: null,
|
||||
};
|
||||
let imessageRuntime: {
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
cliPath?: string | null;
|
||||
dbPath?: string | null;
|
||||
} = {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
cliPath: null,
|
||||
dbPath: null,
|
||||
};
|
||||
const clients = new Set<Client>();
|
||||
let seq = 0;
|
||||
// Track per-run sequence to detect out-of-order/lost agent events.
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const dedupe = new Map<string, DedupeEntry>();
|
||||
// Map agent sessionId -> pending chat runs for WebChat clients.
|
||||
// Map agent runId -> pending chat runs for WebChat clients.
|
||||
const chatRunSessions = new Map<
|
||||
string,
|
||||
Array<{ sessionKey: string; clientRunId: string }>
|
||||
@@ -1812,6 +1857,21 @@ export async function startGatewayServer(
|
||||
if (!queue.length) chatRunSessions.delete(sessionId);
|
||||
return entry;
|
||||
};
|
||||
const resolveSessionKeyForRun = (runId: string) => {
|
||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||
if (cached) return cached;
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const found = Object.entries(store).find(
|
||||
([, entry]) => entry?.sessionId === runId,
|
||||
);
|
||||
const sessionKey = found?.[0];
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
}
|
||||
return sessionKey;
|
||||
};
|
||||
const chatRunBuffers = new Map<string, string>();
|
||||
const chatDeltaSentAt = new Map<string, number>();
|
||||
const chatAbortControllers = new Map<
|
||||
@@ -2221,11 +2281,92 @@ export async function startGatewayServer(
|
||||
};
|
||||
};
|
||||
|
||||
const startIMessageProvider = async () => {
|
||||
if (imessageTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.imessage) {
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logIMessage.info("skipping provider start (imessage not configured)");
|
||||
return;
|
||||
}
|
||||
if (cfg.imessage?.enabled === false) {
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
running: false,
|
||||
lastError: "disabled",
|
||||
};
|
||||
logIMessage.info("skipping provider start (imessage.enabled=false)");
|
||||
return;
|
||||
}
|
||||
const cliPath = cfg.imessage?.cliPath?.trim() || "imsg";
|
||||
const dbPath = cfg.imessage?.dbPath?.trim();
|
||||
logIMessage.info(
|
||||
`starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
||||
);
|
||||
imessageAbort = new AbortController();
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
lastError: null,
|
||||
cliPath,
|
||||
dbPath: dbPath ?? null,
|
||||
};
|
||||
const task = monitorIMessageProvider({
|
||||
cliPath,
|
||||
dbPath,
|
||||
allowFrom: cfg.imessage?.allowFrom,
|
||||
includeAttachments: cfg.imessage?.includeAttachments,
|
||||
mediaMaxMb: cfg.imessage?.mediaMaxMb,
|
||||
runtime: imessageRuntimeEnv,
|
||||
abortSignal: imessageAbort.signal,
|
||||
})
|
||||
.catch((err) => {
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
lastError: formatError(err),
|
||||
};
|
||||
logIMessage.error(`provider exited: ${formatError(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
imessageAbort = null;
|
||||
imessageTask = null;
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
};
|
||||
});
|
||||
imessageTask = task;
|
||||
};
|
||||
|
||||
const stopIMessageProvider = async () => {
|
||||
if (!imessageAbort && !imessageTask) return;
|
||||
imessageAbort?.abort();
|
||||
try {
|
||||
await imessageTask;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
imessageAbort = null;
|
||||
imessageTask = null;
|
||||
imessageRuntime = {
|
||||
...imessageRuntime,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const startProviders = async () => {
|
||||
await startWhatsAppProvider();
|
||||
await startDiscordProvider();
|
||||
await startTelegramProvider();
|
||||
await startSignalProvider();
|
||||
await startIMessageProvider();
|
||||
};
|
||||
|
||||
const broadcast = (
|
||||
@@ -3269,7 +3410,8 @@ export async function startGatewayServer(
|
||||
const provider =
|
||||
channel === "whatsapp" ||
|
||||
channel === "telegram" ||
|
||||
channel === "signal"
|
||||
channel === "signal" ||
|
||||
channel === "imessage"
|
||||
? channel
|
||||
: undefined;
|
||||
const to =
|
||||
@@ -3586,81 +3728,152 @@ export async function startGatewayServer(
|
||||
broadcast("agent", evt);
|
||||
|
||||
const chatLink = peekChatRun(evt.runId);
|
||||
if (chatLink) {
|
||||
// Map agent bus events to chat events for WS WebChat clients.
|
||||
// Use clientRunId so the webchat can correlate with its pending promise.
|
||||
const { sessionKey, clientRunId } = chatLink;
|
||||
bridgeSendToSession(sessionKey, "agent", evt);
|
||||
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
const base = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
chatRunBuffers.set(clientRunId, evt.data.text);
|
||||
const now = Date.now();
|
||||
const last = chatDeltaSentAt.get(clientRunId) ?? 0;
|
||||
// Throttle UI delta events so slow clients don't accumulate unbounded buffers.
|
||||
if (now - last >= 150) {
|
||||
chatDeltaSentAt.set(clientRunId, now);
|
||||
const payload = {
|
||||
...base,
|
||||
state: "delta" as const,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: evt.data.text }],
|
||||
timestamp: now,
|
||||
},
|
||||
const sessionKey =
|
||||
chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
|
||||
const jobState =
|
||||
evt.stream === "job" && typeof evt.data?.state === "string"
|
||||
? evt.data.state
|
||||
: null;
|
||||
|
||||
if (sessionKey) {
|
||||
if (chatLink) {
|
||||
// Map agent bus events to chat events for WS WebChat clients.
|
||||
// Use clientRunId so the webchat can correlate with its pending promise.
|
||||
const { clientRunId } = chatLink;
|
||||
bridgeSendToSession(sessionKey, "agent", evt);
|
||||
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
const base = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
chatRunBuffers.set(clientRunId, evt.data.text);
|
||||
const now = Date.now();
|
||||
const last = chatDeltaSentAt.get(clientRunId) ?? 0;
|
||||
// Throttle UI delta events so slow clients don't accumulate unbounded buffers.
|
||||
if (now - last >= 150) {
|
||||
chatDeltaSentAt.set(clientRunId, now);
|
||||
const payload = {
|
||||
...base,
|
||||
state: "delta" as const,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: evt.data.text }],
|
||||
timestamp: now,
|
||||
},
|
||||
};
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
}
|
||||
} else if (jobState === "done" || jobState === "error") {
|
||||
const finished = shiftChatRun(evt.runId);
|
||||
if (!finished) {
|
||||
if (jobState) clearAgentRunContext(evt.runId);
|
||||
return;
|
||||
}
|
||||
const { sessionKey: finishedSessionKey, clientRunId: finishedRunId } =
|
||||
finished;
|
||||
const base = {
|
||||
runId: finishedRunId,
|
||||
sessionKey: finishedSessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
const text = chatRunBuffers.get(finishedRunId)?.trim() ?? "";
|
||||
chatRunBuffers.delete(finishedRunId);
|
||||
chatDeltaSentAt.delete(finishedRunId);
|
||||
if (jobState === "done") {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "final",
|
||||
message: text
|
||||
? {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(finishedSessionKey, "chat", payload);
|
||||
} else {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "error",
|
||||
errorMessage: evt.data.error
|
||||
? formatForLog(evt.data.error)
|
||||
: undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(finishedSessionKey, "chat", payload);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
evt.stream === "job" &&
|
||||
typeof evt.data?.state === "string" &&
|
||||
(evt.data.state === "done" || evt.data.state === "error")
|
||||
) {
|
||||
const finished = shiftChatRun(evt.runId);
|
||||
if (!finished) {
|
||||
return;
|
||||
}
|
||||
const { sessionKey: finishedSessionKey, clientRunId: finishedRunId } =
|
||||
finished;
|
||||
const base = {
|
||||
runId: finishedRunId,
|
||||
sessionKey: finishedSessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
const text = chatRunBuffers.get(finishedRunId)?.trim() ?? "";
|
||||
chatRunBuffers.delete(finishedRunId);
|
||||
chatDeltaSentAt.delete(finishedRunId);
|
||||
if (evt.data.state === "done") {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "final",
|
||||
message: text
|
||||
? {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
} else {
|
||||
const clientRunId = evt.runId;
|
||||
bridgeSendToSession(sessionKey, "agent", evt);
|
||||
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
const base = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(finishedSessionKey, "chat", payload);
|
||||
} else {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "error",
|
||||
errorMessage: evt.data.error
|
||||
? formatForLog(evt.data.error)
|
||||
: undefined,
|
||||
chatRunBuffers.set(clientRunId, evt.data.text);
|
||||
const now = Date.now();
|
||||
const last = chatDeltaSentAt.get(clientRunId) ?? 0;
|
||||
if (now - last >= 150) {
|
||||
chatDeltaSentAt.set(clientRunId, now);
|
||||
const payload = {
|
||||
...base,
|
||||
state: "delta" as const,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: evt.data.text }],
|
||||
timestamp: now,
|
||||
},
|
||||
};
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
}
|
||||
} else if (jobState === "done" || jobState === "error") {
|
||||
const base = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq: evt.seq,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(finishedSessionKey, "chat", payload);
|
||||
const text = chatRunBuffers.get(clientRunId)?.trim() ?? "";
|
||||
chatRunBuffers.delete(clientRunId);
|
||||
chatDeltaSentAt.delete(clientRunId);
|
||||
if (jobState === "done") {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "final",
|
||||
message: text
|
||||
? {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
} else {
|
||||
const payload = {
|
||||
...base,
|
||||
state: "error",
|
||||
errorMessage: evt.data.error
|
||||
? formatForLog(evt.data.error)
|
||||
: undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jobState === "done" || jobState === "error") {
|
||||
clearAgentRunContext(evt.runId);
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeatUnsub = onHeartbeatEvent((evt) => {
|
||||
@@ -4116,6 +4329,16 @@ export async function startGatewayServer(
|
||||
signalLastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const imessageCfg = cfg.imessage;
|
||||
const imessageEnabled = imessageCfg?.enabled !== false;
|
||||
const imessageConfigured = Boolean(imessageCfg) && imessageEnabled;
|
||||
let imessageProbe: IMessageProbe | undefined;
|
||||
let imessageLastProbeAt: number | null = null;
|
||||
if (probe && imessageConfigured) {
|
||||
imessageProbe = await probeIMessage(timeoutMs);
|
||||
imessageLastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const linked = await webAuthExists();
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
const self = readWebSelfId();
|
||||
@@ -4169,6 +4392,17 @@ export async function startGatewayServer(
|
||||
probe: signalProbe,
|
||||
lastProbeAt: signalLastProbeAt,
|
||||
},
|
||||
imessage: {
|
||||
configured: imessageConfigured,
|
||||
running: imessageRuntime.running,
|
||||
lastStartAt: imessageRuntime.lastStartAt ?? null,
|
||||
lastStopAt: imessageRuntime.lastStopAt ?? null,
|
||||
lastError: imessageRuntime.lastError ?? null,
|
||||
cliPath: imessageRuntime.cliPath ?? null,
|
||||
dbPath: imessageRuntime.dbPath ?? null,
|
||||
probe: imessageProbe,
|
||||
lastProbeAt: imessageLastProbeAt,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -6022,7 +6256,9 @@ export async function startGatewayServer(
|
||||
}
|
||||
const to = params.to.trim();
|
||||
const message = params.message.trim();
|
||||
const provider = (params.provider ?? "whatsapp").toLowerCase();
|
||||
const providerRaw = (params.provider ?? "whatsapp").toLowerCase();
|
||||
const provider =
|
||||
providerRaw === "imsg" ? "imessage" : providerRaw;
|
||||
try {
|
||||
if (provider === "telegram") {
|
||||
const cfg = loadConfig();
|
||||
@@ -6083,6 +6319,27 @@ export async function startGatewayServer(
|
||||
payload,
|
||||
});
|
||||
respond(true, payload, undefined, { provider });
|
||||
} else if (provider === "imessage") {
|
||||
const cfg = loadConfig();
|
||||
const result = await sendMessageIMessage(to, message, {
|
||||
mediaUrl: params.mediaUrl,
|
||||
cliPath: cfg.imessage?.cliPath,
|
||||
dbPath: cfg.imessage?.dbPath,
|
||||
maxBytes: cfg.imessage?.mediaMaxMb
|
||||
? cfg.imessage.mediaMaxMb * 1024 * 1024
|
||||
: undefined,
|
||||
});
|
||||
const payload = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
provider,
|
||||
};
|
||||
dedupe.set(`send:${idem}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
respond(true, payload, undefined, { provider });
|
||||
} else {
|
||||
const result = await sendMessageWhatsApp(to, message, {
|
||||
mediaUrl: params.mediaUrl,
|
||||
@@ -6197,9 +6454,13 @@ export async function startGatewayServer(
|
||||
|
||||
const requestedChannelRaw =
|
||||
typeof params.channel === "string" ? params.channel.trim() : "";
|
||||
const requestedChannel = requestedChannelRaw
|
||||
const requestedChannelNormalized = requestedChannelRaw
|
||||
? requestedChannelRaw.toLowerCase()
|
||||
: "last";
|
||||
const requestedChannel =
|
||||
requestedChannelNormalized === "imsg"
|
||||
? "imessage"
|
||||
: requestedChannelNormalized;
|
||||
|
||||
const lastChannel = sessionEntry?.lastChannel;
|
||||
const lastTo =
|
||||
@@ -6220,6 +6481,7 @@ export async function startGatewayServer(
|
||||
requestedChannel === "telegram" ||
|
||||
requestedChannel === "discord" ||
|
||||
requestedChannel === "signal" ||
|
||||
requestedChannel === "imessage" ||
|
||||
requestedChannel === "webchat"
|
||||
) {
|
||||
return requestedChannel;
|
||||
@@ -6239,7 +6501,8 @@ export async function startGatewayServer(
|
||||
resolvedChannel === "whatsapp" ||
|
||||
resolvedChannel === "telegram" ||
|
||||
resolvedChannel === "discord" ||
|
||||
resolvedChannel === "signal"
|
||||
resolvedChannel === "signal" ||
|
||||
resolvedChannel === "imessage"
|
||||
) {
|
||||
return lastTo || undefined;
|
||||
}
|
||||
@@ -6485,6 +6748,7 @@ export async function startGatewayServer(
|
||||
await stopTelegramProvider();
|
||||
await stopDiscordProvider();
|
||||
await stopSignalProvider();
|
||||
await stopIMessageProvider();
|
||||
cron.stop();
|
||||
heartbeatRunner.stop();
|
||||
broadcast("shutdown", {
|
||||
@@ -6522,7 +6786,9 @@ export async function startGatewayServer(
|
||||
await stopBrowserControlServerIfStarted().catch(() => {});
|
||||
}
|
||||
await Promise.allSettled(
|
||||
[whatsappTask, telegramTask, signalTask].filter(Boolean) as Array<
|
||||
[whatsappTask, telegramTask, signalTask, imessageTask].filter(
|
||||
Boolean,
|
||||
) as Array<
|
||||
Promise<unknown>
|
||||
>,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { emitAgentEvent, onAgentEvent } from "./agent-events.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
onAgentEvent,
|
||||
registerAgentRunContext,
|
||||
getAgentRunContext,
|
||||
clearAgentRunContext,
|
||||
resetAgentRunContextForTest,
|
||||
} from "./agent-events.js";
|
||||
|
||||
describe("agent-events sequencing", () => {
|
||||
test("stores and clears run context", async () => {
|
||||
resetAgentRunContextForTest();
|
||||
registerAgentRunContext("run-1", { sessionKey: "main" });
|
||||
expect(getAgentRunContext("run-1")?.sessionKey).toBe("main");
|
||||
clearAgentRunContext("run-1");
|
||||
expect(getAgentRunContext("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("maintains monotonic seq per runId", async () => {
|
||||
const seen: Record<string, number[]> = {};
|
||||
const stop = onAgentEvent((evt) => {
|
||||
|
||||
@@ -13,9 +13,41 @@ export type AgentEventPayload = {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentRunContext = {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
// Keep per-run counters so streams stay strictly monotonic per runId.
|
||||
const seqByRun = new Map<string, number>();
|
||||
const listeners = new Set<(evt: AgentEventPayload) => void>();
|
||||
const runContextById = new Map<string, AgentRunContext>();
|
||||
|
||||
export function registerAgentRunContext(
|
||||
runId: string,
|
||||
context: AgentRunContext,
|
||||
) {
|
||||
if (!runId) return;
|
||||
const existing = runContextById.get(runId);
|
||||
if (!existing) {
|
||||
runContextById.set(runId, { ...context });
|
||||
return;
|
||||
}
|
||||
if (context.sessionKey && existing.sessionKey !== context.sessionKey) {
|
||||
existing.sessionKey = context.sessionKey;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentRunContext(runId: string) {
|
||||
return runContextById.get(runId);
|
||||
}
|
||||
|
||||
export function clearAgentRunContext(runId: string) {
|
||||
runContextById.delete(runId);
|
||||
}
|
||||
|
||||
export function resetAgentRunContextForTest() {
|
||||
runContextById.clear();
|
||||
}
|
||||
|
||||
export function emitAgentEvent(event: Omit<AgentEventPayload, "seq" | "ts">) {
|
||||
const nextSeq = (seqByRun.get(event.runId) ?? 0) + 1;
|
||||
|
||||
Reference in New Issue
Block a user