fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc)

This commit is contained in:
Peter Steinberger 2026-01-15 23:06:22 +00:00
parent 57d3c8572f
commit 1561b1c491
10 changed files with 41 additions and 41 deletions

View File

@ -8,6 +8,7 @@
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). - Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- Docs: add Date & Time guide and update prompt/timezone configuration docs. - Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot``act`. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot``act`.

View File

@ -54,11 +54,7 @@ import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-
import { splitSdkTools } from "./tool-split.js"; import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js"; import type { EmbeddedPiCompactResult } from "./types.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
describeUnknownError,
mapThinkingLevel,
resolveExecToolDefaults,
} from "./utils.js";
export async function compactEmbeddedPiSession(params: { export async function compactEmbeddedPiSession(params: {
sessionId: string; sessionId: string;
@ -227,9 +223,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider); const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat( const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
params.config?.agents?.defaults?.timeFormat,
);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey, sessionKey: params.sessionKey,

View File

@ -138,16 +138,16 @@ describe("handleDiscordMessagingAction", () => {
}); });
it("adds normalized timestamps to readMessages payloads", async () => { it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([ readMessagesDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" }]);
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
]);
const result = await handleDiscordMessagingAction( const result = await handleDiscordMessagingAction(
"readMessages", "readMessages",
{ channelId: "C1" }, { channelId: "C1" },
enableAllActions, enableAllActions,
); );
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> }; const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z"); const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
expect(payload.messages[0].timestampMs).toBe(expectedMs); expect(payload.messages[0].timestampMs).toBe(expectedMs);
@ -173,16 +173,16 @@ describe("handleDiscordMessagingAction", () => {
}); });
it("adds normalized timestamps to listPins payloads", async () => { it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([ listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" },
]);
const result = await handleDiscordMessagingAction( const result = await handleDiscordMessagingAction(
"listPins", "listPins",
{ channelId: "C1" }, { channelId: "C1" },
enableAllActions, enableAllActions,
); );
const payload = result.details as { pins: Array<{ timestampMs?: number; timestampUtc?: string }> }; const payload = result.details as {
pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z"); const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
expect(payload.pins[0].timestampMs).toBe(expectedMs); expect(payload.pins[0].timestampMs).toBe(expectedMs);

View File

@ -334,7 +334,9 @@ describe("handleSlackAction", () => {
}); });
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> }; const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Math.round(1735689600.456 * 1000); const expectedMs = Math.round(1735689600.456 * 1000);
expect(payload.messages[0].timestampMs).toBe(expectedMs); expect(payload.messages[0].timestampMs).toBe(expectedMs);

View File

@ -263,9 +263,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
// Selecting the default model shows "reset to default" instead of "set to" // Selecting the default model shows "reset to default" instead of "set to"
expect(normalizeTestText(text ?? "")).toContain( expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5");
"anthropic/claude-opus-4-5",
);
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
// When selecting the default, overrides are cleared // When selecting the default, overrides are cleared

View File

@ -63,24 +63,30 @@ export function createDiscordMessageHandler(params: {
onFlush: async (entries) => { onFlush: async (entries) => {
const last = entries.at(-1); const last = entries.at(-1);
if (!last) return; if (!last) return;
const combinedBaseText = if (entries.length === 1) {
entries.length === 1 const ctx = await preflightDiscordMessage({
? resolveDiscordMessageText(last.data.message, { includeForwarded: false }) ...params,
: entries ackReactionScope,
.map((entry) => groupPolicy,
resolveDiscordMessageText(entry.data.message, { includeForwarded: false }), data: last.data,
) client: last.client,
.filter(Boolean) });
.join("\n"); if (!ctx) return;
await processDiscordMessage(ctx);
return;
}
const combinedBaseText = entries
.map((entry) => resolveDiscordMessageText(entry.data.message, { includeForwarded: false }))
.filter(Boolean)
.join("\n");
const syntheticMessage = { const syntheticMessage = {
...last.data.message, ...last.data.message,
content: combinedBaseText, content: combinedBaseText,
attachments: [], attachments: [],
message_snapshots: [], message_snapshots: (last.data.message as { message_snapshots?: unknown }).message_snapshots,
messageSnapshots: [], messageSnapshots: (last.data.message as { messageSnapshots?: unknown }).messageSnapshots,
rawData: { rawData: {
...(last.data.message as { rawData?: Record<string, unknown> }).rawData, ...(last.data.message as { rawData?: Record<string, unknown> }).rawData,
message_snapshots: [],
}, },
}; };
const syntheticData: DiscordMessageEvent = { const syntheticData: DiscordMessageEvent = {
@ -96,9 +102,7 @@ export function createDiscordMessageHandler(params: {
}); });
if (!ctx) return; if (!ctx) return;
if (entries.length > 1) { if (entries.length > 1) {
const ids = entries const ids = entries.map((entry) => entry.data.message?.id).filter(Boolean) as string[];
.map((entry) => entry.data.message?.id)
.filter(Boolean) as string[];
if (ids.length > 0) { if (ids.length > 0) {
const ctxBatch = ctx as typeof ctx & { const ctxBatch = ctx as typeof ctx & {
MessageSids?: string[]; MessageSids?: string[];

View File

@ -86,7 +86,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const conversationId = const conversationId =
entry.message.chat_id != null entry.message.chat_id != null
? `chat:${entry.message.chat_id}` ? `chat:${entry.message.chat_id}`
: entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown"; : (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`; return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
}, },
shouldDebounce: (entry) => { shouldDebounce: (entry) => {
@ -119,7 +119,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
}); });
async function handleMessageNow(message: IMessagePayload) { async function handleMessageNow(message: IMessagePayload) {
const senderRaw = message.sender ?? ""; const senderRaw = message.sender ?? "";
const sender = senderRaw.trim(); const sender = senderRaw.trim();
if (!sender) return; if (!sender) return;

View File

@ -109,7 +109,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
Body: combinedBody, Body: combinedBody,
RawBody: entry.bodyText, RawBody: entry.bodyText,
CommandBody: entry.bodyText, CommandBody: entry.bodyText,
From: entry.isGroup ? `group:${entry.groupId ?? "unknown"}` : `signal:${entry.senderRecipient}`, From: entry.isGroup
? `group:${entry.groupId ?? "unknown"}`
: `signal:${entry.senderRecipient}`,
To: signalTo, To: signalTo,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,
@ -207,7 +209,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const inboundDebouncer = createInboundDebouncer<SignalInboundEntry>({ const inboundDebouncer = createInboundDebouncer<SignalInboundEntry>({
debounceMs: inboundDebounceMs, debounceMs: inboundDebounceMs,
buildKey: (entry) => { buildKey: (entry) => {
const conversationId = entry.isGroup ? entry.groupId ?? "unknown" : entry.senderPeerId; const conversationId = entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId;
if (!conversationId || !entry.senderPeerId) return null; if (!conversationId || !entry.senderPeerId) return null;
return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`; return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`;
}, },

View File

@ -45,7 +45,7 @@ export function createSlackMessageHandler(params: {
if (!last) return; if (!last) return;
const combinedText = const combinedText =
entries.length === 1 entries.length === 1
? last.message.text ?? "" ? (last.message.text ?? "")
: entries : entries
.map((entry) => entry.message.text ?? "") .map((entry) => entry.message.text ?? "")
.filter(Boolean) .filter(Boolean)

View File

@ -66,7 +66,7 @@ export async function monitorWebInbox(options: {
buildKey: (msg) => { buildKey: (msg) => {
const senderKey = const senderKey =
msg.chatType === "group" msg.chatType === "group"
? msg.senderJid ?? msg.senderE164 ?? msg.senderName ?? msg.from ? (msg.senderJid ?? msg.senderE164 ?? msg.senderName ?? msg.from)
: msg.from; : msg.from;
if (!senderKey) return null; if (!senderKey) return null;
const conversationKey = msg.chatType === "group" ? msg.chatId : msg.from; const conversationKey = msg.chatType === "group" ? msg.chatId : msg.from;