diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0d980ee7c..19f8f7308a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 48ebea31ab0..ebea30f8afa 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -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 | 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(), diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 3ee3b4e653d..5367a122043 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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}`, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 8638f17f237..460c6c46c71 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -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 = { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index cde1c34dfa0..90db0f6c0ef 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -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,