[BACKPORT] fix(slack): expose channel reaction events to thread turns (#122363) (#122828)

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
This commit is contained in:
Sarah Fortune
2026-08-12 14:55:15 -07:00
committed by GitHub
parent ac3130b9cc
commit fa796bf42d
7 changed files with 218 additions and 23 deletions
@@ -3703,6 +3703,8 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(root.ctxPayload.SessionKey).toBe(expectedSessionKey);
expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey);
expect(new Set([root.ctxPayload.SessionKey, followUp.ctxPayload.SessionKey]).size).toBe(1);
expect(root.ctxPayload).not.toHaveProperty("SystemEventSessionKey");
expect(followUp.ctxPayload).not.toHaveProperty("SystemEventSessionKey");
if (expectedAgentId) {
expect(root.route.agentId).toBe(expectedAgentId);
}
@@ -1592,7 +1592,8 @@ export async function prepareSlackMessage(params: {
agentId: route.agentId,
dmScope: route.dmScope,
accountId: route.accountId,
routeSessionKey: sessionKey,
routeSessionKey: route.sessionKey,
dispatchSessionKey: sessionKey,
parentSessionKey: threadKeys.parentSessionKey,
},
reply: {
@@ -48,6 +48,7 @@ import {
resolveRoutedDeliveryThreadId,
} from "./routed-delivery-thread.js";
import { drainFormattedSystemEvents } from "./session-system-events.js";
import { getReplySystemEventSessionKey } from "./system-event-session-key.js";
export async function prepareReplyRunAdmission(context: PreparedReplyRunContext) {
const {
@@ -130,19 +131,30 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
? `[Thread starter - for context]\n${threadStarterBody}`
: undefined;
const drainedSystemEventBlocks: string[] = [];
const rebuildPromptBodies = async () => {
if (!useFastReplyRuntime && heartbeatRunScope !== "commitment-only") {
const drainSystemEventBlocks = async () => {
if (useFastReplyRuntime || heartbeatRunScope === "commitment-only") {
return;
}
const routeSystemEventSessionKey = normalizeOptionalString(getReplySystemEventSessionKey(opts));
const systemEventSessionKeys =
routeSystemEventSessionKey && routeSystemEventSessionKey !== sessionKey
? [routeSystemEventSessionKey, sessionKey]
: [sessionKey];
for (const systemEventSessionKey of systemEventSessionKeys) {
const isCurrentSession = systemEventSessionKey === sessionKey;
const eventsBlock = await drainFormattedSystemEvents({
cfg,
sessionKey,
isMainSession,
isNewSession,
sessionKey: systemEventSessionKey,
isMainSession: isCurrentSession && isMainSession,
isNewSession: isCurrentSession && isNewSession,
suppressHeartbeatOwnedEvents: context.isHeartbeat,
});
if (eventsBlock) {
drainedSystemEventBlocks.push(eventsBlock);
}
}
};
const rebuildPromptBodies = () => {
const { activeGoalContext, inboundUserContext } = context.getInboundContext();
return buildReplyPromptEnvelope({
ctx,
@@ -558,6 +570,17 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
return { kind: "reply", reply: queueState.reply } as const;
}
}
if (activeRunQueueAction !== "drop") {
await traceRunPhase("reply.drain_system_events", () => drainSystemEventBlocks());
({
prefixedCommandBody,
queuedBody,
transcriptBody,
transcriptCommandBody,
media: promptMedia,
currentInboundContext,
} = await traceRunPhase("reply.build_prompt_bodies", () => rebuildPromptBodies()));
}
return {
kind: "ready",
@@ -10,6 +10,11 @@ import {
} from "../../agents/embedded-agent-runner/runs.js";
import type { SessionEntry } from "../../config/sessions.js";
import { HEARTBEAT_RUN_SCOPE } from "../../infra/heartbeat-run-scope.js";
import {
enqueueSystemEvent,
peekSystemEventEntries,
resetSystemEventsForTest,
} from "../../infra/system-events.js";
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
import { runReplyAgent } from "./agent-runner.runtime.js";
@@ -31,6 +36,7 @@ import { testing as replyRunTesting } from "./reply-run-registry.test-support.js
import { routeReply } from "./route-reply.runtime.js";
import { drainFormattedSystemEvents } from "./session-system-events.js";
import { buildChannelSourceTurnId } from "./source-turn-id.js";
import { withReplySystemEventSessionKey } from "./system-event-session-key.js";
import { resolveTypingMode } from "./typing-mode.js";
vi.mock("../../agents/auth-profiles/session-override.js", () => ({
@@ -371,6 +377,7 @@ describe("runPreparedReply media-only handling", () => {
afterEach(() => {
vi.useRealTimers();
resetSystemEventsForTest();
const paths = cleanupPaths.splice(0);
return Promise.all(paths.map((entry) => rm(entry, { recursive: true, force: true })));
});
@@ -2253,12 +2260,72 @@ describe("runPreparedReply media-only handling", () => {
nextRun.complete();
});
it("re-drains system events after waiting behind an active run", async () => {
it("keeps route and dispatch system events queued when busy admission returns", async () => {
vi.useFakeTimers();
const actualSystemEvents = await vi.importActual<typeof import("./session-system-events.js")>(
"./session-system-events.js",
);
vi.mocked(drainFormattedSystemEvents).mockImplementation(
actualSystemEvents.drainFormattedSystemEvents,
);
const queueSettings = await import("./queue/settings-runtime.js");
vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "interrupt" });
vi.mocked(drainFormattedSystemEvents)
.mockResolvedValueOnce("System: [t] Initial event.")
.mockResolvedValueOnce("System: [t] Post-compaction context.");
const routeSessionKey = "agent:main:slack:channel:c123";
const dispatchSessionKey = `${routeSessionKey}:thread:123.456`;
enqueueSystemEvent("Slack reaction added: :eyes:", { sessionKey: routeSessionKey });
enqueueSystemEvent("Slack message in #claw-test from Alice", {
sessionKey: dispatchSessionKey,
});
const previousRun = createReplyOperation({
sessionId: "session-before-wait",
sessionKey: dispatchSessionKey,
resetTriggered: false,
});
previousRun.setPhase("running");
const runPromise = runPreparedReply(
baseParams({
isNewSession: false,
sessionId: "session-before-wait",
sessionKey: dispatchSessionKey,
opts: withReplySystemEventSessionKey({}, routeSessionKey),
}),
);
await Promise.resolve();
previousRun.complete();
const nextRun = createReplyOperation({
sessionId: "session-after-wait",
sessionKey: dispatchSessionKey,
resetTriggered: false,
});
nextRun.setPhase("running");
const assertion = expect(runPromise).resolves.toEqual({
text: "⚠️ Previous run is still shutting down. Please try again in a moment.",
});
await vi.advanceTimersByTimeAsync(15_000);
await assertion;
expect(vi.mocked(runReplyAgent)).not.toHaveBeenCalled();
expect(peekSystemEventEntries(routeSessionKey).map((event) => event.text)).toEqual([
"Slack reaction added: :eyes:",
]);
expect(peekSystemEventEntries(dispatchSessionKey).map((event) => event.text)).toEqual([
"Slack message in #claw-test from Alice",
]);
nextRun.complete();
});
it("drains system events only after waiting behind an active run", async () => {
const actualSystemEvents = await vi.importActual<typeof import("./session-system-events.js")>(
"./session-system-events.js",
);
vi.mocked(drainFormattedSystemEvents).mockImplementation(
actualSystemEvents.drainFormattedSystemEvents,
);
const queueSettings = await import("./queue/settings-runtime.js");
vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "interrupt" });
enqueueSystemEvent("System event after active run", { sessionKey: "session-key" });
const previousRun = createReplyOperation({
sessionId: "session-events-after-wait",
@@ -2275,16 +2342,18 @@ describe("runPreparedReply media-only handling", () => {
);
await Promise.resolve();
expect(peekSystemEventEntries("session-key").map((event) => event.text)).toEqual([
"System event after active run",
]);
previousRun.complete();
await expect(runPromise).resolves.toEqual({ text: "ok" });
const call = requireLastRunReplyAgentCall();
expect(call?.commandBody).toContain("System: [t] Initial event.");
expect(call?.commandBody).not.toContain("System: [t] Post-compaction context.");
expect(call?.transcriptCommandBody).not.toContain("System: [t] Initial event.");
expect(call?.followupRun.prompt).toContain("System: [t] Initial event.");
expect(call?.followupRun.prompt).not.toContain("System: [t] Post-compaction context.");
expect(call?.followupRun.transcriptPrompt).not.toContain("System: [t] Initial event.");
expect(call?.commandBody).toContain("System event after active run");
expect(call?.transcriptCommandBody).not.toContain("System event after active run");
expect(call?.followupRun.prompt).toContain("System event after active run");
expect(call?.followupRun.transcriptPrompt).not.toContain("System event after active run");
expect(peekSystemEventEntries("session-key")).toStrictEqual([]);
});
it("threads inbound context as current-turn context without changing transcript text", async () => {
@@ -3488,6 +3557,38 @@ describe("runPreparedReply media-only handling", () => {
expect(applySessionHints).not.toHaveBeenCalled();
});
it("includes route system events in a thread-scoped turn", async () => {
const actualSystemEvents = await vi.importActual<typeof import("./session-system-events.js")>(
"./session-system-events.js",
);
vi.mocked(drainFormattedSystemEvents).mockImplementation(
actualSystemEvents.drainFormattedSystemEvents,
);
enqueueSystemEvent("Slack reaction added: :eyes:", {
sessionKey: "agent:main:slack:channel:c123",
});
enqueueSystemEvent("Slack message in #claw-test from Alice", {
sessionKey: "agent:main:slack:channel:c123:thread:123.456",
});
await runPreparedReply(
baseParams({
ctx: createInboundBody("report queued reactions"),
isNewSession: false,
opts: withReplySystemEventSessionKey({}, "agent:main:slack:channel:c123"),
sessionKey: "agent:main:slack:channel:c123:thread:123.456",
}),
);
const prompt = requireRunReplyAgentCall().followupRun.prompt;
expect(prompt).toContain("Slack reaction added: :eyes:");
expect(prompt).toContain("Slack message in #claw-test from Alice");
expect(peekSystemEventEntries("agent:main:slack:channel:c123")).toStrictEqual([]);
expect(peekSystemEventEntries("agent:main:slack:channel:c123:thread:123.456")).toStrictEqual(
[],
);
});
it("keeps sender ownership when queued system events are prepended", async () => {
vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce(
"System: [t] External webhook payload.",
@@ -0,0 +1,21 @@
const REPLY_SYSTEM_EVENT_SESSION_KEY = Symbol("openclaw.reply.systemEventSessionKey");
/** Attach route-owned system-event state without widening public reply option contracts. */
export function withReplySystemEventSessionKey<T extends object>(
options: T,
sessionKey: string,
): T {
return {
...options,
[REPLY_SYSTEM_EVENT_SESSION_KEY]: sessionKey,
};
}
/** Read route-owned system-event state after it crosses internal reply-option spreads. */
export function getReplySystemEventSessionKey(options: object | undefined): string | undefined {
if (!options) {
return undefined;
}
const value = (options as Record<PropertyKey, unknown>)[REPLY_SYSTEM_EVENT_SESSION_KEY];
return typeof value === "string" ? value : undefined;
}
+44
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "v
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { HistoryEntry } from "../../auto-reply/reply/history.types.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import { getReplySystemEventSessionKey } from "../../auto-reply/reply/system-event-session-key.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
@@ -296,6 +297,49 @@ describe("channel turn kernel", () => {
}
});
it.each([
{
channel: "slack",
routeSessionKey: "agent:main:slack:channel:c1",
dispatchSessionKey: "agent:main:slack:channel:c1:thread:123.456",
},
{
channel: "discord",
routeSessionKey: "agent:main:discord:channel:c1",
dispatchSessionKey: "agent:main:discord:channel:c1:thread:t1",
},
])("carries $channel route system-event ownership privately into dispatch", async (scenario) => {
const { channel, routeSessionKey, dispatchSessionKey } = scenario;
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
expect(params.ctx).not.toHaveProperty("SystemEventSessionKey");
expect(getReplySystemEventSessionKey({ ...params.replyOptions })).toBe(routeSessionKey);
await params.dispatcherOptions.deliver({ text: "reply" }, { kind: "final" });
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
},
) as DispatchReplyWithBufferedBlockDispatcher;
await dispatchAssembledChannelTurn({
cfg,
channel,
agentId: "main",
routeSessionKey,
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({
SessionKey: dispatchSessionKey,
Surface: channel,
Provider: channel,
}),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async () => ({ visibleReplySent: true }),
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
});
it("runs routed direct message hooks after payload preparation", async () => {
const events: string[] = [];
const runMessageSending = vi.fn(async (event: { content: string }) => {
+10 -7
View File
@@ -3,6 +3,7 @@ import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchFromConfigResult } from "../../auto-reply/reply/dispatch-from-config.types.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
import { runWithSessionInitConflictRetry } from "../../auto-reply/reply/session-init-conflict-retry.js";
import { withReplySystemEventSessionKey } from "../../auto-reply/reply/system-event-session-key.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import {
deriveInboundMessageHookContext,
@@ -96,14 +97,17 @@ export function assembleResolvedChannelTurn<
function resolveAssembledReplyPipeline(
params: DispatchableChannelTurn,
): Pick<AssembledChannelTurn, "dispatcherOptions" | "replyOptions"> {
const turnAdoptionLifecycle =
params.turnAdoptionLifecycle ?? params.replyOptions?.turnAdoptionLifecycle;
const adoption = params.turnAdoptionLifecycle ?? params.replyOptions?.turnAdoptionLifecycle;
let replyOptions = adoption
? { ...params.replyOptions, turnAdoptionLifecycle: adoption }
: params.replyOptions;
if (params.routeSessionKey !== params.ctxPayload.SessionKey) {
replyOptions = withReplySystemEventSessionKey(replyOptions ?? {}, params.routeSessionKey);
}
if (!params.replyPipeline) {
return {
dispatcherOptions: params.dispatcherOptions,
replyOptions: turnAdoptionLifecycle
? { ...params.replyOptions, turnAdoptionLifecycle }
: params.replyOptions,
replyOptions,
};
}
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
@@ -120,8 +124,7 @@ function resolveAssembledReplyPipeline(
},
replyOptions: {
onModelSelected,
...params.replyOptions,
...(turnAdoptionLifecycle ? { turnAdoptionLifecycle } : {}),
...replyOptions,
},
};
}