fix: scope history injection to pending-only

This commit is contained in:
Peter Steinberger
2026-01-16 23:52:14 +00:00
parent 56ed5cc2d9
commit e31251293b
21 changed files with 278 additions and 175 deletions

View File

@@ -20,6 +20,7 @@
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Channels: inject only pending (mention-gated) group history; clear history on any processed message.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.

View File

@@ -202,9 +202,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
- `always`: always triggers.
- `/activation mention|always` is owner-only and must be sent as a standalone message.
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
- **History injection**:
- Recent messages (default 50) inserted under:
`[Chat messages since your last reply - for context]`
- **History injection** (pending-only):
- Recent *unprocessed* messages (default 50) inserted under:
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
- Current message under:
`[Current message - respond to this]`
- Sender suffix appended: `[from: Name (+E164)]`

View File

@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
- Context injection: **pending-only** group messages (default 50) that *did not* trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its a group chat.

View File

@@ -221,7 +221,7 @@ Notes:
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels; use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
## Group allowlists
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.

View File

@@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript.
Directive stripping only applies to the **current message** section so history
remains intact. Channels that wrap history should set `CommandBody` (or
`RawBody`) to the original message text and keep `Body` as the combined prompt.

View File

@@ -6,9 +6,10 @@ import {
} from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
@@ -302,6 +303,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
teamConfig,
channelConfig,
});
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
if (!isDirectMessage) {
const mentionGate = resolveMentionGating({
@@ -319,11 +321,22 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
if (historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
}
return;
}
}
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
@@ -352,16 +365,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
@@ -432,7 +439,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
@@ -446,7 +453,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {

View File

@@ -4,6 +4,7 @@ import {
buildHistoryContext,
buildHistoryContextFromEntries,
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
HISTORY_CONTEXT_MARKER,
} from "./history.js";
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
@@ -81,4 +82,27 @@ describe("history helpers", () => {
expect(result).toContain("B: two");
expect(result).not.toContain("C: three");
});
it("builds context from pending map without appending", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
historyMap.set("room", [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
]);
const result = buildPendingHistoryContextFromMap({
historyMap,
historyKey: "room",
limit: 3,
currentMessage: "current",
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
});
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["one", "two"]);
expect(result).toContain(HISTORY_CONTEXT_MARKER);
expect(result).toContain("A: one");
expect(result).toContain("B: two");
expect(result).toContain(CURRENT_MESSAGE_MARKER);
expect(result).toContain("current");
});
});

View File

@@ -23,12 +23,12 @@ export function buildHistoryContext(params: {
);
}
export function appendHistoryEntry(params: {
historyMap: Map<string, HistoryEntry[]>;
export function appendHistoryEntry<T extends HistoryEntry>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry: HistoryEntry;
entry: T;
limit: number;
}): HistoryEntry[] {
}): T[] {
const { historyMap, historyKey, entry } = params;
if (params.limit <= 0) return [];
const history = historyMap.get(historyKey) ?? [];
@@ -38,6 +38,34 @@ export function appendHistoryEntry(params: {
return history;
}
export function recordPendingHistoryEntry<T extends HistoryEntry>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry: T;
limit: number;
}): T[] {
return appendHistoryEntry(params);
}
export function buildPendingHistoryContextFromMap(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
currentMessage: string;
formatEntry: (entry: HistoryEntry) => string;
lineBreak?: string;
}): string {
if (params.limit <= 0) return params.currentMessage;
const entries = params.historyMap.get(params.historyKey) ?? [];
return buildHistoryContextFromEntries({
entries,
currentMessage: params.currentMessage,
formatEntry: params.formatEntry,
lineBreak: params.lineBreak,
excludeLast: false,
});
}
export function buildHistoryContextFromMap(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;

View File

@@ -2,7 +2,7 @@ import { ChannelType, MessageType, type User } from "@buape/carbon";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
@@ -353,6 +353,14 @@ export async function preflightDiscordMessage(
},
"discord: skipping guild message",
);
if (historyEntry && params.historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: params.guildHistories,
historyKey: message.channelId,
limit: params.historyLimit,
entry: historyEntry,
});
}
return null;
}
}

View File

@@ -10,7 +10,10 @@ import {
} from "../../auto-reply/reply/response-prefix-template.js";
import { formatAgentEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildHistoryContextFromMap, clearHistoryEntries } from "../../auto-reply/reply/history.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
} from "../../auto-reply/reply/history.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
@@ -59,7 +62,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
shouldRequireMention,
canDetectMention,
effectiveWasMentioned,
historyEntry,
threadChannel,
threadParentId,
threadParentName,
@@ -130,15 +132,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
timestamp: resolveTimestampMs(message.timestamp),
body: text,
});
let shouldClearHistory = false;
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
if (shouldIncludeChannelHistory) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: guildHistories,
historyKey: message.channelId,
limit: historyLimit,
entry: historyEntry,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
@@ -148,7 +148,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
}),
});
shouldClearHistory = true;
}
if (!isDirectMessage) {
const name = formatDiscordUserTag(author);
@@ -279,7 +278,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
);
}
let didSendReply = false;
const typingChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: message.channelId;
@@ -306,7 +304,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
});
didSendReply = true;
replyReference.markSent();
},
onError: (err, info) => {
@@ -337,7 +334,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
});
markDispatchIdle();
if (!queuedFinal) {
if (isGuildMessage && shouldClearHistory && historyLimit > 0 && didSendReply) {
if (isGuildMessage && historyLimit > 0) {
clearHistoryEntries({
historyMap: guildHistories,
historyKey: message.channelId,
@@ -345,7 +342,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
return;
}
didSendReply = true;
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
@@ -365,7 +361,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
});
});
}
if (isGuildMessage && shouldClearHistory && historyLimit > 0 && didSendReply) {
if (isGuildMessage && historyLimit > 0) {
clearHistoryEntries({
historyMap: guildHistories,
historyKey: message.channelId,

View File

@@ -16,9 +16,10 @@ import {
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
@@ -260,6 +261,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const messageText = (message.text ?? "").trim();
const attachments = includeAttachments ? (message.attachments ?? []) : [];
const firstAttachment = attachments?.find((entry) => entry?.original_path && !entry?.missing);
const mediaPath = firstAttachment?.original_path ?? undefined;
const mediaType = firstAttachment?.mime_type ?? undefined;
const kind = mediaKindFromMime(mediaType ?? undefined);
const placeholder = kind ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
const bodyText = messageText || placeholder;
if (!bodyText) return;
const createdAt = message.created_at ? Date.parse(message.created_at) : undefined;
const historyKey = isGroup
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
: undefined;
const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true;
const requireMention = resolveChannelGroupRequireMention({
cfg,
@@ -290,23 +303,26 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const effectiveWasMentioned = mentioned || shouldBypassMention;
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
logVerbose(`imessage: skipping group message (no mention)`);
if (historyKey && historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: normalizeIMessageHandle(sender),
body: bodyText,
timestamp: createdAt,
messageId: message.id ? String(message.id) : undefined,
},
});
}
return;
}
const attachments = includeAttachments ? (message.attachments ?? []) : [];
const firstAttachment = attachments?.find((entry) => entry?.original_path && !entry?.missing);
const mediaPath = firstAttachment?.original_path ?? undefined;
const mediaType = firstAttachment?.mime_type ?? undefined;
const kind = mediaKindFromMime(mediaType ?? undefined);
const placeholder = kind ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
const bodyText = messageText || placeholder;
if (!bodyText) return;
const chatTarget = formatIMessageChatTarget(chatId);
const fromLabel = isGroup
? `${message.chat_name || "iMessage Group"} id:${chatId ?? "unknown"}`
: `${normalizeIMessageHandle(sender)} id:${sender}`;
const createdAt = message.created_at ? Date.parse(message.created_at) : undefined;
const body = formatAgentEnvelope({
channel: "iMessage",
from: fromLabel,
@@ -314,20 +330,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
body: bodyText,
});
let combinedBody = body;
const historyKey = isGroup
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
: undefined;
if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: normalizeIMessageHandle(sender),
body: bodyText,
timestamp: createdAt,
messageId: message.id ? String(message.id) : undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
@@ -393,8 +400,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
let didSendReply = false;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
@@ -414,7 +419,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
maxBytes: mediaMaxBytes,
textLimit,
});
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`));
@@ -440,12 +444,12 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
if (!queuedFinal) {
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
return;
}
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
}

View File

@@ -14,7 +14,10 @@ import {
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildHistoryContextFromMap, clearHistoryEntries } from "../../auto-reply/reply/history.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
} from "../../auto-reply/reply/history.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
@@ -72,16 +75,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
let combinedBody = body;
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: deps.groupHistories,
historyKey,
limit: deps.historyLimit,
entry: {
sender: entry.senderName,
body: entry.bodyText,
timestamp: entry.timestamp ?? undefined,
messageId: entry.messageId,
},
currentMessage: combinedBody,
formatEntry: (historyEntry) =>
formatAgentEnvelope({
@@ -150,8 +147,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
let didSendReply = false;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(deps.cfg, route.agentId),
@@ -172,7 +167,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit,
});
didSendReply = true;
},
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
@@ -196,12 +190,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
});
if (!queuedFinal) {
if (entry.isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
return;
}
if (entry.isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
}

View File

@@ -264,7 +264,7 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock.mock.calls[1][1]).toBe("final reply");
});
it("wraps room history in Body and preserves RawBody", async () => {
it("preserves RawBody without injecting processed room history", async () => {
config = {
messages: { ackReactionScope: "group-mentions" },
channels: {
@@ -320,9 +320,9 @@ describe("monitorSlackProvider tool results", () => {
await run;
expect(replyMock).toHaveBeenCalledTimes(2);
expect(capturedCtx.Body).toContain(HISTORY_CONTEXT_MARKER);
expect(capturedCtx.Body).toContain(CURRENT_MESSAGE_MARKER);
expect(capturedCtx.Body).toContain("first");
expect(capturedCtx.Body).not.toContain(HISTORY_CONTEXT_MARKER);
expect(capturedCtx.Body).not.toContain(CURRENT_MESSAGE_MARKER);
expect(capturedCtx.Body).not.toContain("first");
expect(capturedCtx.RawBody).toBe("second");
expect(capturedCtx.CommandBody).toBe("second");
});
@@ -334,7 +334,7 @@ describe("monitorSlackProvider tool results", () => {
slack: {
historyLimit: 5,
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: false } },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
@@ -372,7 +372,7 @@ describe("monitorSlackProvider tool results", () => {
event: {
type: "message",
user: "U1",
text: "thread-a-two",
text: "<@bot-user> thread-a-two",
ts: "201",
thread_ts: "100",
channel: "C1",
@@ -384,7 +384,7 @@ describe("monitorSlackProvider tool results", () => {
event: {
type: "message",
user: "U2",
text: "thread-b-one",
text: "<@bot-user> thread-b-one",
ts: "301",
thread_ts: "300",
channel: "C1",
@@ -396,10 +396,10 @@ describe("monitorSlackProvider tool results", () => {
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(3);
expect(capturedCtx[1]?.Body).toContain("thread-a-one");
expect(capturedCtx[2]?.Body).not.toContain("thread-a-one");
expect(capturedCtx[2]?.Body).not.toContain("thread-a-two");
expect(replyMock).toHaveBeenCalledTimes(2);
expect(capturedCtx[0]?.Body).toContain("thread-a-one");
expect(capturedCtx[1]?.Body).not.toContain("thread-a-one");
expect(capturedCtx[1]?.Body).not.toContain("thread-a-two");
});
it("updates assistant thread status when replies start", async () => {

View File

@@ -66,8 +66,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
};
let didSendReply = false;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
@@ -88,7 +86,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
textLimit: ctx.textLimit,
replyThreadTs,
});
didSendReply = true;
replyPlan.markSent();
},
onError: (err, info) => {
@@ -136,7 +133,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
if (!queuedFinal) {
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
if (prepared.isRoomish && ctx.historyLimit > 0) {
clearHistoryEntries({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,
@@ -168,7 +165,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
}
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
if (prepared.isRoomish && ctx.historyLimit > 0) {
clearHistoryEntries({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,

View File

@@ -2,7 +2,10 @@ import { resolveAckReaction } from "../../../agents/identity.js";
import { hasControlCommand } from "../../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js";
import { formatAgentEnvelope, formatThreadStarterEnvelope } from "../../../auto-reply/envelope.js";
import { buildHistoryContextFromMap } from "../../../auto-reply/reply/history.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntry,
} from "../../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
@@ -167,6 +170,19 @@ export async function prepareSlackMessage(params: {
},
});
const baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
const isThreadReply = hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: isThreadReply ? threadTs : undefined,
parentSessionKey: isThreadReply && ctx.threadInheritParent ? baseSessionKey : undefined,
});
const sessionKey = threadKeys.sessionKey;
const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
opts.wasMentioned ??
@@ -233,6 +249,28 @@ export async function prepareSlackMessage(params: {
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping room message");
if (ctx.historyLimit > 0) {
const pendingText = (message.text ?? "").trim();
const fallbackFile = message.files?.[0]?.name
? `[Slack file: ${message.files[0].name}]`
: message.files?.length
? "[Slack file]"
: "";
const pendingBody = pendingText || fallbackFile;
if (pendingBody) {
recordPendingHistoryEntry({
historyMap: ctx.channelHistories,
historyKey,
limit: ctx.historyLimit,
entry: {
sender: senderName,
body: pendingBody,
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
messageId: message.ts,
},
});
}
}
return null;
}
@@ -277,16 +315,6 @@ export async function prepareSlackMessage(params: {
: null;
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const historyEntry =
isRoomish && ctx.historyLimit > 0
? {
sender: senderName,
body: rawBody,
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
messageId: message.ts,
}
: undefined;
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Slack DM from ${senderName}`
@@ -297,18 +325,6 @@ export async function prepareSlackMessage(params: {
? `slack:channel:${message.channel}`
: `slack:group:${message.channel}`;
const baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
const isThreadReply = hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: isThreadReply ? threadTs : undefined,
parentSessionKey: isThreadReply && ctx.threadInheritParent ? baseSessionKey : undefined,
});
const sessionKey = threadKeys.sessionKey;
const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
@@ -324,11 +340,10 @@ export async function prepareSlackMessage(params: {
let combinedBody = body;
if (isRoomish && ctx.historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: ctx.channelHistories,
historyKey,
limit: ctx.historyLimit,
entry: historyEntry,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({

View File

@@ -3,7 +3,10 @@ import { resolveAckReaction } from "../agents/identity.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { buildHistoryContextFromMap } from "../auto-reply/reply/history.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntry,
} from "../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
@@ -200,6 +203,25 @@ export const buildTelegramMessageContext = async ({
senderId,
senderUsername,
});
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = "";
if (msg.photo) placeholder = "<media:image>";
else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
else if (msg.document) placeholder = "<media:document>";
const locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined;
const rawText = (msg.text ?? msg.caption ?? "").trim();
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) rawBody = placeholder;
if (!rawBody && allMedia.length === 0) return null;
let bodyText = rawBody;
if (!bodyText && allMedia.length > 0) {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
const computedWasMentioned =
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
@@ -243,6 +265,19 @@ export const buildTelegramMessageContext = async ({
if (isGroup && requireMention && canDetectMention) {
if (mentionGate.shouldSkip) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
if (historyKey && historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: buildSenderLabel(msg, senderId || chatId),
body: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
},
});
}
return null;
}
}
@@ -283,25 +318,7 @@ export const buildTelegramMessageContext = async ({
)
: null;
let placeholder = "";
if (msg.photo) placeholder = "<media:image>";
else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
else if (msg.document) placeholder = "<media:document>";
const replyTarget = describeReplyTarget(msg);
const locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined;
const rawText = (msg.text ?? msg.caption ?? "").trim();
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) rawBody = placeholder;
if (!rawBody && allMedia.length === 0) return null;
let bodyText = rawBody;
if (!bodyText && allMedia.length > 0) {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
const replySuffix = replyTarget
? `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
@@ -317,18 +334,11 @@ export const buildTelegramMessageContext = async ({
body: `${bodyText}${replySuffix}`,
});
let combinedBody = body;
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
combinedBody = buildPendingHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: buildSenderLabel(msg, senderId || chatId),
body: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({

View File

@@ -124,7 +124,6 @@ export const dispatchTelegramMessage = async ({
identityName: resolveIdentityName(cfg, route.agentId),
};
let didSendReply = false;
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
@@ -147,7 +146,6 @@ export const dispatchTelegramMessage = async ({
messageThreadId: resolvedThreadId,
onVoiceRecording: sendRecordVoice,
});
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
@@ -174,7 +172,7 @@ export const dispatchTelegramMessage = async ({
});
draftStream?.stop();
if (!queuedFinal) {
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
return;
@@ -189,7 +187,7 @@ export const dispatchTelegramMessage = async ({
});
});
}
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
};

View File

@@ -106,7 +106,7 @@ describe("web auto-reply", () => {
vi.useRealTimers();
});
it("supports always-on group activation with silent token and preserves history", async () => {
it("supports always-on group activation with silent token and clears pending history", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
@@ -180,9 +180,9 @@ describe("web auto-reply", () => {
expect(resolver).toHaveBeenCalledTimes(2);
const payload = resolver.mock.calls[1][0];
expect(payload.Body).toContain("Chat messages since your last reply");
expect(payload.Body).toContain("Alice (+111): first");
expect(payload.Body).toContain("[message_id: g-always-1]");
expect(payload.Body).not.toContain("Chat messages since your last reply");
expect(payload.Body).not.toContain("Alice (+111): first");
expect(payload.Body).not.toContain("[message_id: g-always-1]");
expect(payload.Body).toContain("Bob: second");
expect(reply).toHaveBeenCalledTimes(1);

View File

@@ -77,17 +77,15 @@ export async function maybeBroadcastMessage(params: {
}
};
let didSendReply = false;
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
if (await processForAgent(agentId)) didSendReply = true;
await processForAgent(agentId);
}
} else {
const results = await Promise.allSettled(broadcastAgents.map(processForAgent));
didSendReply = results.some((result) => result.status === "fulfilled" && result.value);
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
if (params.msg.chatType === "group" && didSendReply) {
if (params.msg.chatType === "group") {
params.groupHistories.set(params.groupHistoryKey, []);
}

View File

@@ -6,6 +6,7 @@ import { resolveMentionGating } from "../../../channels/mention-gating.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { recordPendingHistoryEntry } from "../../../auto-reply/reply/history.js";
import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js";
import { noteGroupMember } from "./group-members.js";
@@ -65,26 +66,27 @@ export function applyGroupGating(params: {
if (activationCommand.hasCommand && !owner) {
params.logVerbose(`Ignoring /activation from non-owner in group ${params.conversationId}`);
if (params.groupHistoryLimit > 0) {
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
recordPendingHistoryEntry({
historyMap: params.groupHistories,
historyKey: params.groupHistoryKey,
limit: params.groupHistoryLimit,
entry: {
sender,
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
},
});
}
return { shouldProcess: false };
}
if (!shouldBypassMention) {
const history = params.groupHistories.get(params.groupHistoryKey) ?? [];
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
history.push({
sender,
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
});
while (history.length > params.groupHistoryLimit) history.shift();
params.groupHistories.set(params.groupHistoryKey, history);
}
const mentionDebug = debugMention(params.msg, mentionConfig, params.authDir);
params.replyLogger.debug(
{
@@ -124,6 +126,24 @@ export function applyGroupGating(params: {
params.logVerbose(
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
);
if (params.groupHistoryLimit > 0) {
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
recordPendingHistoryEntry({
historyMap: params.groupHistories,
historyKey: params.groupHistoryKey,
limit: params.groupHistoryLimit,
entry: {
sender,
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
},
});
}
return { shouldProcess: false };
}

View File

@@ -76,10 +76,9 @@ export async function processMessage(params: {
if (params.msg.chatType === "group") {
const history = params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [];
const historyWithoutCurrent = history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) {
if (history.length > 0) {
const lineBreak = "\\n";
const historyText = historyWithoutCurrent
const historyText = history
.map((m) => {
const bodyWithId = m.id ? `${m.body}\n[message_id: ${m.id}]` : m.body;
return formatAgentEnvelope({
@@ -299,14 +298,14 @@ export async function processMessage(params: {
});
if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) {
if (shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver");
return false;
}
if (shouldClearGroupHistory && didSendReply) {
if (shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}