fix(slack): scope dm last-route updates

Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-10 16:44:49 +01:00
parent b025c30276
commit a4eee2ccc2
5 changed files with 153 additions and 6 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Slack: describe `download-file` file ids separately from message timestamps and return a targeted recovery error when agents pass `messageId` instead of `fileId`. (#74155) Thanks @jarvis-ai-gregmoser.
- Slack: retain processed room messages for `requireMention=false` channels so always-on Slack rooms keep recent conversation context between turns. (#38658) Thanks @syedamaann.
- Slack: compile interactive reply directives for direct outbound sends without bypassing the `interactiveReplies` capability gate, preserving Block Kit for Slack CLI and cron deliveries. (#78220) Thanks @kazamak.
- Slack: keep DM last-route updates scoped to the active non-main DM session, including threaded DM turns, so isolated Slack DM sessions do not overwrite the shared main route. (#73085) Thanks @clawSean.
- Gateway/agents: keep structured reasons when active-run queueing fails and deprecate the legacy boolean queue helper, so steering and subagent wake diagnostics distinguish completed, non-streaming, and compacting runs. Fixes #80156. Thanks @markus-lassfolk.
- Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines.
- ACPX: run and await the embedded ACP backend startup probe by default so the gateway `ready` signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0` to restore lazy startup. Fixes #79596. Thanks @bzelones.

View File

@@ -8,6 +8,7 @@ const createSlackDraftStreamMock = vi.fn();
const deliverRepliesMock = vi.fn(async () => {});
const finalizeSlackPreviewEditMock = vi.fn(async () => {});
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const updateLastRouteMock = vi.fn(async () => {});
const appendSlackStreamMock = vi.fn(async () => {});
const startSlackStreamMock = vi.fn(async () => ({
channel: "C123",
@@ -192,6 +193,14 @@ function createPreparedSlackMessage(params?: {
user: string;
}>;
replyToMode?: "off" | "first" | "all" | "batched";
isDirectMessage?: boolean;
route?: Partial<{
agentId: string;
accountId: string;
mainSessionKey: string;
sessionKey: string;
lastRoutePolicy: "main" | "session";
}>;
setSlackThreadStatus?: (params: {
channelId: string;
threadTs?: string;
@@ -201,6 +210,11 @@ function createPreparedSlackMessage(params?: {
ackReactionMessageTs?: string;
ackReactionPromise?: Promise<boolean> | null;
}) {
const routeSessionKey = params?.route?.sessionKey ?? "agent:agent-1:slack:C123";
const mainSessionKey = params?.route?.mainSessionKey ?? "main";
const lastRoutePolicy =
params?.route?.lastRoutePolicy ?? (routeSessionKey === mainSessionKey ? "main" : "session");
return {
ctx: {
cfg: params?.cfg ?? {},
@@ -230,8 +244,10 @@ function createPreparedSlackMessage(params?: {
route: {
agentId: "agent-1",
accountId: "default",
mainSessionKey: "main",
sessionKey: "agent:agent-1:slack:C123",
mainSessionKey,
sessionKey: routeSessionKey,
lastRoutePolicy,
...params?.route,
},
channelConfig: null,
replyTarget: "channel:C123",
@@ -244,7 +260,7 @@ function createPreparedSlackMessage(params?: {
record: {},
},
replyToMode: params?.replyToMode ?? "all",
isDirectMessage: false,
isDirectMessage: params?.isDirectMessage ?? false,
isRoomish: false,
historyKey: "history-key",
preview: "",
@@ -588,7 +604,7 @@ vi.mock("../allow-list.js", () => ({
vi.mock("../config.runtime.js", () => ({
resolveStorePath: () => "/tmp/openclaw-store.json",
updateLastRoute: async () => {},
updateLastRoute: updateLastRouteMock,
}));
vi.mock("../replies.js", () => ({
@@ -699,6 +715,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
deliverRepliesMock.mockReset();
finalizeSlackPreviewEditMock.mockReset();
postMessageMock.mockClear();
updateLastRouteMock.mockReset();
appendSlackStreamMock.mockReset();
startSlackStreamMock.mockReset();
stopSlackStreamMock.mockReset();
@@ -741,6 +758,75 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("updates non-main DM last-route metadata on the prepared thread session", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
cfg: { session: { dmScope: "per-channel-peer" } },
isDirectMessage: true,
message: {
channel: "D123",
user: "U1",
ts: "501.000",
thread_ts: "500.000",
},
route: {
agentId: "main",
mainSessionKey: "agent:main:main",
sessionKey: "agent:main:slack:direct:u1",
lastRoutePolicy: "session",
},
ctxPayload: {
MessageThreadId: "500.000",
SessionKey: "agent:main:slack:direct:u1:thread:500.000",
},
}),
);
expect(updateLastRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:slack:direct:u1:thread:500.000",
deliveryContext: expect.objectContaining({
threadId: "500.000",
to: "user:U1",
}),
}),
);
});
it("keeps default main-scope DM last-route metadata on the main session", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
isDirectMessage: true,
message: {
channel: "D123",
user: "U1",
ts: "601.000",
thread_ts: "600.000",
},
route: {
agentId: "main",
mainSessionKey: "agent:main:main",
sessionKey: "agent:main:main",
lastRoutePolicy: "main",
},
ctxPayload: {
MessageThreadId: "600.000",
SessionKey: "agent:main:main:thread:600.000",
},
}),
);
expect(updateLastRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
deliveryContext: expect.objectContaining({
threadId: "600.000",
to: "user:U1",
}),
}),
);
});
it("finalizes fast draft preview text without sending a duplicate normal reply", async () => {
const draftStream = {
...createDraftStreamStub(),

View File

@@ -38,6 +38,7 @@ import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runti
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -331,7 +332,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
} else {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
sessionKey: resolveInboundLastRouteSessionKey({
route,
sessionKey: prepared.ctxPayload.SessionKey ?? route.sessionKey,
}),
deliveryContext: {
channel: "slack",
to: `user:${message.user}`,

View File

@@ -1278,6 +1278,61 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared.ctxPayload.MessageThreadId).toBe("500.000");
});
it("records non-main DM last-route metadata on the prepared thread session", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const slackCtx = createInboundSlackCtx({
cfg: {
session: { store: storePath, dmScope: "per-channel-peer" },
channels: { slack: { enabled: true, replyToMode: "all" } },
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ replyToMode: "all" }),
createSlackMessage({
text: "thread reply",
ts: "501.000",
thread_ts: "500.000",
}),
);
assertPrepared(prepared);
expect(prepared.route.sessionKey).toBe("agent:main:slack:direct:u1");
expect(prepared.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1:thread:500.000");
expect(
(prepared.turn.record as { updateLastRoute?: { sessionKey?: string } }).updateLastRoute,
).toEqual(expect.objectContaining({ sessionKey: prepared.ctxPayload.SessionKey }));
});
it("keeps default main-scope DM last-route metadata on the main session", async () => {
const slackCtx = createInboundSlackCtx({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all" } },
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ replyToMode: "all" }),
createSlackMessage({
text: "thread reply",
ts: "601.000",
thread_ts: "600.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.SessionKey).toBe("agent:main:main:thread:600.000");
expect(
(prepared.turn.record as { updateLastRoute?: { sessionKey?: string } }).updateLastRoute,
).toEqual(expect.objectContaining({ sessionKey: "agent:main:main" }));
});
it("routes Slack thread replies through runtime conversation bindings", async () => {
const targetSessionKey = "agent:review:acp:session-67739";
const binding: SessionBindingRecord = {

View File

@@ -21,6 +21,7 @@ import {
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import {
@@ -991,7 +992,7 @@ export async function prepareSlackMessage(params: {
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
sessionKey: resolveInboundLastRouteSessionKey({ route, sessionKey }),
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,