mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
* fix(slack): include bot root message in new thread sessions (#79338) When a user replies in-thread to a bot's own message in a Slack DM, the new thread session was constructed without the parent/root message content. The agent only saw `reply_to_id` metadata and could not resolve what was being replied to, leading to confident-but-wrong actions on follow-up corrections. The thread-context resolver was filtering out every message authored by the current bot before formatting thread history, including the bot's own root message. For thread-replies starting a fresh session, that left the agent without the parent context it needed. This change retains current-bot messages in the thread history when starting a new thread session, formats them with role=assistant under a "Bot (this assistant)" sender label, and adds `channels.slack.thread.includeRootMessage` (default `true`) to opt out. Bot messages still bypass allowlist visibility filtering since the bot's own output is not third-party content. Fixes #79338. * fix(slack): wire includeRootMessage into runtime config schema (#79338) The first commit added `channels.slack.thread.includeRootMessage` to the TypeScript type and zod schema, but the runtime AJV-style schema generated from `extensions/slack/src/config-ui-hints.ts` rejected the new field with `must NOT have additional properties` at gateway boot. Adds the matching UI hint entry for `thread.includeRootMessage` and regenerates the bundled channel config metadata so the live gateway accepts the new field. * Narrow Slack thread root context handling Remove the public includeRootMessage config and keep the Slack thread fix focused on including only the current bot's root message on the first turn of a new thread session. Preserve filtering of arbitrary current-bot Slack history while ensuring #79338 has parent/root context. * Fix Slack thread root CI checks --------- Co-authored-by: Bek <bek.akhmedov@gmail.com>
This commit is contained in:
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack: include the bot's own root/parent message in new thread sessions so in-thread replies reach the agent with the parent text the user is responding to, instead of only `reply_to_id` metadata. Fixes #79338. Thanks @sxxtony.
|
||||
- Memory: skip managed dreaming cron reconciliation warnings for ordinary cron and heartbeat hook contexts that cannot manage Gateway cron. (#77027) Thanks @rubencu.
|
||||
- Yuanbao: bump `openclaw-plugin-yuanbao` to 2.13.1 to support `sourceReplyDeliveryMode: "automatic"` for group chat. (#79814) Thanks @loongfay.
|
||||
- Memory: keep `memory_search` result `corpus` labels aligned with the hit source, so session transcript hits surface as `sessions` and memory-file hits stay `memory`. Fixes #72885. (#71898, #72886) Thanks @rubencu.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
1cc2cabc41c2261492dea4d927b48864c6992dcfb5b81fa97f2171848d037b1e config-baseline.json
|
||||
bfa974d070e5ef26fab023506b050cb3a582d1e54a096dbf4dddbc59535de29c config-baseline.core.json
|
||||
ebb8fa25af8be3a6c42a8bbf505f119819ee49b3c28a317ae04a244f740be381 config-baseline.json
|
||||
647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json
|
||||
f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json
|
||||
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applySlackThreadHistoryFilterPolicy,
|
||||
ensureSlackThreadHistoryHasBotRoot,
|
||||
formatSlackBotStarterThreadLabel,
|
||||
isSlackThreadAuthorCurrentBot,
|
||||
resolveSlackThreadHistoryFilterPolicy,
|
||||
type SlackThreadRootCandidate,
|
||||
shouldIncludeBotThreadStarterContext,
|
||||
} from "./prepare-thread-context-root.js";
|
||||
|
||||
describe("isSlackThreadAuthorCurrentBot", () => {
|
||||
const identity = { botUserId: "U_BOT", botId: "B1" };
|
||||
|
||||
it("matches the configured bot user id", () => {
|
||||
expect(
|
||||
isSlackThreadAuthorCurrentBot({
|
||||
identity,
|
||||
author: { userId: "U_BOT" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the configured bot id", () => {
|
||||
expect(
|
||||
isSlackThreadAuthorCurrentBot({
|
||||
identity,
|
||||
author: { botId: "B1" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a different bot id", () => {
|
||||
expect(
|
||||
isSlackThreadAuthorCurrentBot({
|
||||
identity,
|
||||
author: { botId: "B2" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a regular user", () => {
|
||||
expect(
|
||||
isSlackThreadAuthorCurrentBot({
|
||||
identity,
|
||||
author: { userId: "U1" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when identity has no bot ids", () => {
|
||||
expect(
|
||||
isSlackThreadAuthorCurrentBot({
|
||||
identity: {},
|
||||
author: { userId: "U_BOT", botId: "B1" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSlackThreadHistoryFilterPolicy", () => {
|
||||
it("retains only the current-bot root when starting a new session with starter text", () => {
|
||||
expect(
|
||||
resolveSlackThreadHistoryFilterPolicy({
|
||||
includeBotStarterAsRootContext: true,
|
||||
starterTs: "1",
|
||||
}),
|
||||
).toEqual({ retainCurrentBotRootTs: "1" });
|
||||
});
|
||||
|
||||
it("filters current-bot messages on existing sessions", () => {
|
||||
expect(
|
||||
resolveSlackThreadHistoryFilterPolicy({
|
||||
includeBotStarterAsRootContext: false,
|
||||
starterTs: "1",
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySlackThreadHistoryFilterPolicy", () => {
|
||||
const identity = { botUserId: "U_BOT", botId: "B1" };
|
||||
|
||||
it("keeps only the current-bot root when policy names the root timestamp", () => {
|
||||
const history = [
|
||||
{ ts: "1", botId: "B1", text: "bot root" },
|
||||
{ ts: "1.5", botId: "B1", text: "assistant reply" },
|
||||
{ ts: "2", userId: "U1", text: "user reply" },
|
||||
];
|
||||
const result = applySlackThreadHistoryFilterPolicy({
|
||||
history,
|
||||
policy: { retainCurrentBotRootTs: "1" },
|
||||
identity,
|
||||
});
|
||||
expect(result.kept.map((entry) => entry.ts)).toEqual(["1", "2"]);
|
||||
expect(result.omittedCurrentBot).toBe(1);
|
||||
});
|
||||
|
||||
it("filters current-bot messages and reports counts when policy excludes them", () => {
|
||||
const history = [
|
||||
{ ts: "1", botId: "B1", text: "bot root" },
|
||||
{ ts: "2", userId: "U_BOT", text: "bot via user id" },
|
||||
{ ts: "3", userId: "U1", text: "user reply" },
|
||||
{ ts: "4", botId: "B2", text: "third-party bot" },
|
||||
];
|
||||
const result = applySlackThreadHistoryFilterPolicy({
|
||||
history,
|
||||
policy: {},
|
||||
identity,
|
||||
});
|
||||
expect(result.kept.map((entry) => entry.ts)).toEqual(["3", "4"]);
|
||||
expect(result.omittedCurrentBot).toBe(2);
|
||||
});
|
||||
|
||||
it("returns an empty result for empty history", () => {
|
||||
const result = applySlackThreadHistoryFilterPolicy({
|
||||
history: [] as Array<{ ts: string; userId?: string; botId?: string }>,
|
||||
policy: {},
|
||||
identity,
|
||||
});
|
||||
expect(result.kept).toEqual([]);
|
||||
expect(result.omittedCurrentBot).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIncludeBotThreadStarterContext", () => {
|
||||
it("includes when starter is bot, session is new, and starter has text", () => {
|
||||
expect(
|
||||
shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot: true,
|
||||
isNewThreadSession: true,
|
||||
hasStarterText: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not include when starter is not the current bot", () => {
|
||||
expect(
|
||||
shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot: false,
|
||||
isNewThreadSession: true,
|
||||
hasStarterText: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not include when session is not new", () => {
|
||||
expect(
|
||||
shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot: true,
|
||||
isNewThreadSession: false,
|
||||
hasStarterText: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not include when starter has no text", () => {
|
||||
expect(
|
||||
shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot: true,
|
||||
isNewThreadSession: true,
|
||||
hasStarterText: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSlackThreadHistoryHasBotRoot", () => {
|
||||
it("keeps the fetched root when history already contains it", () => {
|
||||
const history = [
|
||||
{ ts: "1", botId: "B1", text: "bot root" },
|
||||
{ ts: "2", userId: "U1", text: "user reply" },
|
||||
];
|
||||
expect(
|
||||
ensureSlackThreadHistoryHasBotRoot({
|
||||
history,
|
||||
includeBotStarterAsRootContext: true,
|
||||
threadStarter: { ts: "1", botId: "B1", text: "bot root" },
|
||||
}),
|
||||
).toBe(history);
|
||||
});
|
||||
|
||||
it("prepends the starter root when fetched history omitted it", () => {
|
||||
const history: SlackThreadRootCandidate[] = [{ ts: "2", userId: "U1", text: "user reply" }];
|
||||
expect(
|
||||
ensureSlackThreadHistoryHasBotRoot({
|
||||
history,
|
||||
includeBotStarterAsRootContext: true,
|
||||
threadStarter: { ts: "1", botId: "B1", text: "bot root" },
|
||||
}).map((entry) => entry.text),
|
||||
).toEqual(["bot root", "user reply"]);
|
||||
});
|
||||
|
||||
it("does not inject when bot starter root context is disabled", () => {
|
||||
const history: SlackThreadRootCandidate[] = [{ ts: "2", userId: "U1", text: "user reply" }];
|
||||
expect(
|
||||
ensureSlackThreadHistoryHasBotRoot({
|
||||
history,
|
||||
includeBotStarterAsRootContext: false,
|
||||
threadStarter: { ts: "1", botId: "B1", text: "bot root" },
|
||||
}).map((entry) => entry.text),
|
||||
).toEqual(["user reply"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSlackBotStarterThreadLabel", () => {
|
||||
it("returns base label when starter text is missing", () => {
|
||||
expect(formatSlackBotStarterThreadLabel({ roomLabel: "DM" })).toBe("Slack thread DM");
|
||||
});
|
||||
|
||||
it("returns base label when starter text is empty", () => {
|
||||
expect(formatSlackBotStarterThreadLabel({ roomLabel: "DM", starterText: "" })).toBe(
|
||||
"Slack thread DM",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns base label when starter text collapses to whitespace snippet", () => {
|
||||
expect(formatSlackBotStarterThreadLabel({ roomLabel: "DM", starterText: " " })).toBe(
|
||||
"Slack thread DM",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends an assistant root snippet to the room label", () => {
|
||||
expect(
|
||||
formatSlackBotStarterThreadLabel({
|
||||
roomLabel: "#general",
|
||||
starterText: "Confirmed meeting at noon",
|
||||
}),
|
||||
).toBe("Slack thread #general (assistant root): Confirmed meeting at noon");
|
||||
});
|
||||
|
||||
it("truncates long starter text to 80 characters", () => {
|
||||
const longText = "x".repeat(120);
|
||||
const label = formatSlackBotStarterThreadLabel({ roomLabel: "DM", starterText: longText });
|
||||
expect(label.endsWith("x".repeat(80))).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses internal whitespace", () => {
|
||||
expect(
|
||||
formatSlackBotStarterThreadLabel({
|
||||
roomLabel: "DM",
|
||||
starterText: "Line one\n\nLine two",
|
||||
}),
|
||||
).toBe("Slack thread DM (assistant root): Line one Line two");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
export type SlackBotAuthorIdentity = {
|
||||
botUserId?: string;
|
||||
botId?: string;
|
||||
};
|
||||
|
||||
export type SlackThreadAuthorTuple = {
|
||||
userId?: string;
|
||||
botId?: string;
|
||||
};
|
||||
|
||||
export type SlackThreadRootCandidate = SlackThreadAuthorTuple & {
|
||||
text?: string;
|
||||
ts?: string;
|
||||
};
|
||||
|
||||
export type SlackThreadHistoryFilterPolicy = {
|
||||
retainCurrentBotRootTs?: string;
|
||||
};
|
||||
|
||||
export type SlackThreadHistoryFilterResult<T> = {
|
||||
kept: T[];
|
||||
omittedCurrentBot: number;
|
||||
};
|
||||
|
||||
export function isSlackThreadAuthorCurrentBot(params: {
|
||||
identity: SlackBotAuthorIdentity;
|
||||
author: SlackThreadAuthorTuple;
|
||||
}): boolean {
|
||||
const { identity, author } = params;
|
||||
if (identity.botUserId && author.userId && author.userId === identity.botUserId) {
|
||||
return true;
|
||||
}
|
||||
if (identity.botId && author.botId && author.botId === identity.botId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveSlackThreadHistoryFilterPolicy(params: {
|
||||
includeBotStarterAsRootContext: boolean;
|
||||
starterTs?: string;
|
||||
}): SlackThreadHistoryFilterPolicy {
|
||||
if (!params.includeBotStarterAsRootContext || !params.starterTs) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
retainCurrentBotRootTs: params.starterTs,
|
||||
};
|
||||
}
|
||||
|
||||
export function applySlackThreadHistoryFilterPolicy<T extends SlackThreadRootCandidate>(params: {
|
||||
history: T[];
|
||||
policy: SlackThreadHistoryFilterPolicy;
|
||||
identity: SlackBotAuthorIdentity;
|
||||
}): SlackThreadHistoryFilterResult<T> {
|
||||
const kept: T[] = [];
|
||||
let omittedCurrentBot = 0;
|
||||
for (const entry of params.history) {
|
||||
const isCurrentBot = isSlackThreadAuthorCurrentBot({
|
||||
identity: params.identity,
|
||||
author: entry,
|
||||
});
|
||||
if (!isCurrentBot) {
|
||||
kept.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (params.policy.retainCurrentBotRootTs && entry.ts === params.policy.retainCurrentBotRootTs) {
|
||||
kept.push(entry);
|
||||
} else {
|
||||
omittedCurrentBot += 1;
|
||||
}
|
||||
}
|
||||
return { kept, omittedCurrentBot };
|
||||
}
|
||||
|
||||
export function shouldIncludeBotThreadStarterContext(params: {
|
||||
starterIsCurrentBot: boolean;
|
||||
isNewThreadSession: boolean;
|
||||
hasStarterText: boolean;
|
||||
}): boolean {
|
||||
if (!params.hasStarterText) {
|
||||
return false;
|
||||
}
|
||||
return params.starterIsCurrentBot && params.isNewThreadSession;
|
||||
}
|
||||
|
||||
export function ensureSlackThreadHistoryHasBotRoot<T extends SlackThreadRootCandidate>(params: {
|
||||
history: T[];
|
||||
includeBotStarterAsRootContext: boolean;
|
||||
threadStarter: (T & { ts: string }) | null;
|
||||
}): T[] {
|
||||
if (!params.includeBotStarterAsRootContext || !params.threadStarter?.text) {
|
||||
return params.history;
|
||||
}
|
||||
if (params.history.some((entry) => entry.ts === params.threadStarter?.ts)) {
|
||||
return params.history;
|
||||
}
|
||||
return [params.threadStarter, ...params.history];
|
||||
}
|
||||
|
||||
export function formatSlackBotStarterThreadLabel(params: {
|
||||
roomLabel: string;
|
||||
starterText?: string;
|
||||
}): string {
|
||||
const base = `Slack thread ${params.roomLabel}`;
|
||||
if (!params.starterText) {
|
||||
return base;
|
||||
}
|
||||
const snippet = params.starterText.replace(/\s+/g, " ").slice(0, 80).trim();
|
||||
if (!snippet) {
|
||||
return base;
|
||||
}
|
||||
return `${base} (assistant root): ${snippet}`;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe("resolveSlackThreadContextData", () => {
|
||||
|
||||
async function resolveAllowlistedThreadContext(params: {
|
||||
repliesMessages: Array<Record<string, string>>;
|
||||
threadStarter: { text: string; userId?: string; ts: string; botId?: string };
|
||||
threadStarter: { text: string; userId?: string; ts?: string; botId?: string };
|
||||
allowFromLower: string[];
|
||||
allowNameMatching: boolean;
|
||||
}) {
|
||||
@@ -82,7 +82,7 @@ describe("resolveSlackThreadContextData", () => {
|
||||
return { replies, result };
|
||||
}
|
||||
|
||||
it("omits non-allowlisted starter text and thread history messages", async () => {
|
||||
it("omits non-allowlisted starter, follow-ups, and unrelated current-bot replies", async () => {
|
||||
const { replies, result } = await resolveAllowlistedThreadContext({
|
||||
repliesMessages: [
|
||||
{ text: "starter secret", user: "U2", ts: "100.000" },
|
||||
@@ -110,6 +110,30 @@ describe("resolveSlackThreadContextData", () => {
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters prior current-bot replies from user-started threads on new sessions", async () => {
|
||||
const { result } = await resolveAllowlistedThreadContext({
|
||||
repliesMessages: [
|
||||
{ text: "starter from Alice", user: "U1", ts: "100.000" },
|
||||
{ text: "assistant progress update", bot_id: "B1", ts: "100.200" },
|
||||
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
|
||||
{ text: "current message", user: "U1", ts: "101.000" },
|
||||
],
|
||||
threadStarter: {
|
||||
text: "starter from Alice",
|
||||
userId: "U1",
|
||||
ts: "100.000",
|
||||
},
|
||||
allowFromLower: ["u1"],
|
||||
allowNameMatching: false,
|
||||
});
|
||||
|
||||
expect(result.threadStarterBody).toBe("starter from Alice");
|
||||
expect(result.threadHistoryBody).toContain("starter from Alice");
|
||||
expect(result.threadHistoryBody).toContain("allowed follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("assistant progress update");
|
||||
expect(result.threadHistoryBody).not.toContain("current message");
|
||||
});
|
||||
|
||||
it("keeps starter text and history when allowNameMatching authorizes the sender", async () => {
|
||||
const { result } = await resolveAllowlistedThreadContext({
|
||||
repliesMessages: [
|
||||
@@ -132,7 +156,7 @@ describe("resolveSlackThreadContextData", () => {
|
||||
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
|
||||
});
|
||||
|
||||
it("omits bot-authored starter text and history from a new thread session", async () => {
|
||||
it("includes bot-authored starter as assistant root context for a new thread session (default)", async () => {
|
||||
const { result } = await resolveAllowlistedThreadContext({
|
||||
repliesMessages: [
|
||||
{ text: "bot starter", bot_id: "B1", ts: "100.000" },
|
||||
@@ -142,16 +166,106 @@ describe("resolveSlackThreadContextData", () => {
|
||||
threadStarter: {
|
||||
text: "bot starter",
|
||||
botId: "B1",
|
||||
ts: "100.000",
|
||||
},
|
||||
allowFromLower: ["u1"],
|
||||
allowNameMatching: false,
|
||||
});
|
||||
|
||||
expect(result.threadStarterBody).toBeUndefined();
|
||||
expect(result.threadLabel).toBe("Slack thread #general");
|
||||
expect(result.threadLabel).toBe("Slack thread #general (assistant root): bot starter");
|
||||
expect(result.threadHistoryBody).toContain("allowed follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("bot starter");
|
||||
expect(result.threadHistoryBody).toContain("bot starter");
|
||||
expect(result.threadHistoryBody).toContain("Bot (this assistant) (assistant)");
|
||||
expect(result.threadHistoryBody).not.toContain("current message");
|
||||
});
|
||||
|
||||
it("injects bot-authored starter when fetched history omits the root", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
|
||||
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
|
||||
{ text: "current message", user: "U1", ts: "101.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const ctx = createThreadContext({ replies });
|
||||
ctx.botUserId = "U_BOT";
|
||||
ctx.botId = "B1";
|
||||
ctx.resolveUserName = async (id: string) => ({
|
||||
name: id === "U1" ? "Alice" : "Mallory",
|
||||
});
|
||||
|
||||
const result = await resolveSlackThreadContextData({
|
||||
ctx,
|
||||
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
|
||||
message: createThreadMessage(),
|
||||
isThreadReply: true,
|
||||
threadTs: "100.000",
|
||||
threadStarter: {
|
||||
text: "bot starter",
|
||||
botId: "B1",
|
||||
ts: "100.000",
|
||||
},
|
||||
roomLabel: "#general",
|
||||
storePath,
|
||||
sessionKey: "thread-session",
|
||||
allowFromLower: ["u1"],
|
||||
allowNameMatching: false,
|
||||
contextVisibilityMode: "allowlist",
|
||||
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
|
||||
effectiveDirectMedia: null,
|
||||
});
|
||||
|
||||
expect(result.threadStarterBody).toBeUndefined();
|
||||
expect(result.threadLabel).toBe("Slack thread #general (assistant root): bot starter");
|
||||
expect(result.threadHistoryBody).toContain("bot starter");
|
||||
expect(result.threadHistoryBody).toContain("Bot (this assistant) (assistant)");
|
||||
expect(result.threadHistoryBody).toContain("allowed follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("assistant reply");
|
||||
expect(result.threadHistoryBody).not.toContain("current message");
|
||||
});
|
||||
|
||||
it("injects bot-authored starter when initial history trimming drops the root", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{ text: "bot starter", bot_id: "B1", ts: "100.000" },
|
||||
{ text: "old user follow-up", user: "U1", ts: "100.100" },
|
||||
{ text: "recent user follow-up", user: "U1", ts: "100.900" },
|
||||
{ text: "current message", user: "U1", ts: "101.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const ctx = createThreadContext({ replies });
|
||||
ctx.botUserId = "U_BOT";
|
||||
ctx.botId = "B1";
|
||||
ctx.resolveUserName = async () => ({ name: "Alice" });
|
||||
|
||||
const result = await resolveSlackThreadContextData({
|
||||
ctx,
|
||||
account: createSlackTestAccount({ thread: { initialHistoryLimit: 1 } }),
|
||||
message: createThreadMessage(),
|
||||
isThreadReply: true,
|
||||
threadTs: "100.000",
|
||||
threadStarter: {
|
||||
text: "bot starter",
|
||||
botId: "B1",
|
||||
ts: "100.000",
|
||||
},
|
||||
roomLabel: "#general",
|
||||
storePath,
|
||||
sessionKey: "thread-session",
|
||||
allowFromLower: ["u1"],
|
||||
allowNameMatching: false,
|
||||
contextVisibilityMode: "allowlist",
|
||||
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
|
||||
effectiveDirectMedia: null,
|
||||
});
|
||||
|
||||
expect(result.threadHistoryBody).toContain("bot starter");
|
||||
expect(result.threadHistoryBody).toContain("recent user follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("old user follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("current message");
|
||||
});
|
||||
|
||||
@@ -179,7 +293,7 @@ describe("resolveSlackThreadContextData", () => {
|
||||
expect(result.threadHistoryBody).not.toContain("Unknown (user)");
|
||||
});
|
||||
|
||||
it("omits self-authored starter text when identified by bot user id", async () => {
|
||||
it("includes self-authored starter (identified by bot user id) for a new thread session (default)", async () => {
|
||||
const { result } = await resolveAllowlistedThreadContext({
|
||||
repliesMessages: [
|
||||
{ text: "self starter", user: "U_BOT", ts: "100.000" },
|
||||
@@ -196,7 +310,65 @@ describe("resolveSlackThreadContextData", () => {
|
||||
});
|
||||
|
||||
expect(result.threadStarterBody).toBeUndefined();
|
||||
expect(result.threadLabel).toBe("Slack thread #general (assistant root): self starter");
|
||||
expect(result.threadHistoryBody).toContain("allowed follow-up");
|
||||
expect(result.threadHistoryBody).not.toContain("self starter");
|
||||
expect(result.threadHistoryBody).toContain("self starter");
|
||||
expect(result.threadHistoryBody).toContain("Bot (this assistant) (assistant)");
|
||||
});
|
||||
|
||||
it("issue #79338: bot DM confirmation root is included so reply has parent context", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
text: "Confirmed Saturday 12:30pm meeting with Alice",
|
||||
bot_id: "B1",
|
||||
ts: "100.000",
|
||||
},
|
||||
{
|
||||
text: "actually it's Sunday 12:30 pm - apologize and correct",
|
||||
user: "U1",
|
||||
ts: "101.000",
|
||||
},
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const ctx = createThreadContext({ replies });
|
||||
ctx.botUserId = "U_BOT";
|
||||
ctx.botId = "B1";
|
||||
ctx.resolveUserName = async (id: string) => ({ name: id === "U1" ? "Alice" : "Mallory" });
|
||||
|
||||
const result = await resolveSlackThreadContextData({
|
||||
ctx,
|
||||
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
|
||||
message: createThreadMessage({
|
||||
channel: "D123",
|
||||
channel_type: "im",
|
||||
text: "actually it's Sunday 12:30 pm - apologize and correct",
|
||||
ts: "101.000",
|
||||
}),
|
||||
isThreadReply: true,
|
||||
threadTs: "100.000",
|
||||
threadStarter: {
|
||||
text: "Confirmed Saturday 12:30pm meeting with Alice",
|
||||
botId: "B1",
|
||||
ts: "100.000",
|
||||
},
|
||||
roomLabel: "DM",
|
||||
storePath,
|
||||
sessionKey: "thread-session",
|
||||
allowFromLower: [],
|
||||
allowNameMatching: false,
|
||||
contextVisibilityMode: "all",
|
||||
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
|
||||
effectiveDirectMedia: null,
|
||||
});
|
||||
|
||||
expect(result.threadHistoryBody).toContain("Confirmed Saturday 12:30pm meeting with Alice");
|
||||
expect(result.threadHistoryBody).toContain("Bot (this assistant) (assistant)");
|
||||
expect(result.threadHistoryBody).not.toContain(
|
||||
"actually it's Sunday 12:30 pm - apologize and correct",
|
||||
);
|
||||
expect(result.threadLabel).toContain("Confirmed Saturday 12:30pm");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ import { readSessionUpdatedAt } from "../config.runtime.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackMediaResult } from "../media-types.js";
|
||||
import { resolveSlackThreadHistory, type SlackThreadStarter } from "../thread.js";
|
||||
import {
|
||||
applySlackThreadHistoryFilterPolicy,
|
||||
ensureSlackThreadHistoryHasBotRoot,
|
||||
formatSlackBotStarterThreadLabel,
|
||||
isSlackThreadAuthorCurrentBot,
|
||||
resolveSlackThreadHistoryFilterPolicy,
|
||||
shouldIncludeBotThreadStarterContext,
|
||||
} from "./prepare-thread-context-root.js";
|
||||
|
||||
type SlackMediaModule = typeof import("../media.js");
|
||||
let slackMediaModulePromise: Promise<SlackMediaModule> | undefined;
|
||||
@@ -103,11 +111,12 @@ export async function resolveSlackThreadContextData(params: {
|
||||
>;
|
||||
effectiveDirectMedia: SlackMediaResult[] | null;
|
||||
}): Promise<SlackThreadContextData> {
|
||||
const botIdentity = {
|
||||
botUserId: params.ctx.botUserId,
|
||||
botId: params.ctx.botId,
|
||||
};
|
||||
const isCurrentBotAuthor = (author: { userId?: string; botId?: string }): boolean =>
|
||||
Boolean(
|
||||
(params.ctx.botUserId && author.userId && author.userId === params.ctx.botUserId) ||
|
||||
(params.ctx.botId && author.botId && author.botId === params.ctx.botId),
|
||||
);
|
||||
isSlackThreadAuthorCurrentBot({ identity: botIdentity, author });
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadHistoryBody: string | undefined;
|
||||
@@ -176,21 +185,36 @@ export async function resolveSlackThreadContextData(params: {
|
||||
} else {
|
||||
threadLabel = `Slack thread ${params.roomLabel}`;
|
||||
}
|
||||
if (starter?.text && starterIsCurrentBot) {
|
||||
logVerbose("slack: omitted current-bot thread starter from context");
|
||||
} else if (starter?.text && !includeStarterContext) {
|
||||
logVerbose(
|
||||
`slack: omitted thread starter from context (mode=${params.contextVisibilityMode}, sender_allowed=${starterAllowed ? "yes" : "no"})`,
|
||||
);
|
||||
}
|
||||
|
||||
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
|
||||
threadSessionPreviousTimestamp = readSessionUpdatedAt({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const isNewThreadSession = !threadSessionPreviousTimestamp;
|
||||
const includeBotStarterAsRootContext = shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot,
|
||||
isNewThreadSession,
|
||||
hasStarterText: Boolean(starter?.text),
|
||||
});
|
||||
|
||||
if (starter?.text && starterIsCurrentBot && !includeBotStarterAsRootContext) {
|
||||
logVerbose("slack: omitted current-bot thread starter from context");
|
||||
} else if (starter?.text && !includeStarterContext && !starterIsCurrentBot) {
|
||||
logVerbose(
|
||||
`slack: omitted thread starter from context (mode=${params.contextVisibilityMode}, sender_allowed=${starterAllowed ? "yes" : "no"})`,
|
||||
);
|
||||
} else if (includeBotStarterAsRootContext) {
|
||||
threadLabel = formatSlackBotStarterThreadLabel({
|
||||
roomLabel: params.roomLabel,
|
||||
starterText: starter?.text,
|
||||
});
|
||||
logVerbose("slack: retained current-bot thread starter as assistant root context");
|
||||
}
|
||||
|
||||
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
|
||||
|
||||
if (threadInitialHistoryLimit > 0 && !threadSessionPreviousTimestamp) {
|
||||
const currentBotRootTs = starter?.ts ?? params.threadTs;
|
||||
const threadHistory = await resolveSlackThreadHistory({
|
||||
channelId: params.message.channel,
|
||||
threadTs: params.threadTs,
|
||||
@@ -199,16 +223,25 @@ export async function resolveSlackThreadContextData(params: {
|
||||
limit: threadInitialHistoryLimit,
|
||||
});
|
||||
|
||||
if (threadHistory.length > 0) {
|
||||
const threadHistoryWithoutCurrentBot = threadHistory.filter(
|
||||
(historyMsg) =>
|
||||
!isCurrentBotAuthor({
|
||||
userId: historyMsg.userId,
|
||||
botId: historyMsg.botId,
|
||||
}),
|
||||
);
|
||||
const omittedCurrentBotHistoryCount =
|
||||
threadHistory.length - threadHistoryWithoutCurrentBot.length;
|
||||
const threadHistoryWithBotRoot = ensureSlackThreadHistoryHasBotRoot({
|
||||
history: threadHistory,
|
||||
includeBotStarterAsRootContext,
|
||||
threadStarter: starter ? { ...starter, ts: currentBotRootTs } : null,
|
||||
});
|
||||
|
||||
if (threadHistoryWithBotRoot.length > 0) {
|
||||
const historyFilterPolicy = resolveSlackThreadHistoryFilterPolicy({
|
||||
includeBotStarterAsRootContext,
|
||||
starterTs: currentBotRootTs,
|
||||
});
|
||||
const {
|
||||
kept: threadHistoryWithoutCurrentBot,
|
||||
omittedCurrentBot: omittedCurrentBotHistoryCount,
|
||||
} = applySlackThreadHistoryFilterPolicy({
|
||||
history: threadHistoryWithBotRoot,
|
||||
policy: historyFilterPolicy,
|
||||
identity: botIdentity,
|
||||
});
|
||||
|
||||
const userMapForFilter =
|
||||
params.contextVisibilityMode !== "all" &&
|
||||
@@ -227,6 +260,14 @@ export async function resolveSlackThreadContextData(params: {
|
||||
mode: params.contextVisibilityMode,
|
||||
kind: "thread",
|
||||
isSenderAllowed: (historyMsg) => {
|
||||
if (
|
||||
isCurrentBotAuthor({
|
||||
userId: historyMsg.userId,
|
||||
botId: historyMsg.botId,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const msgUser = historyMsg.userId ? userMapForFilter.get(historyMsg.userId) : null;
|
||||
return isSlackThreadContextSenderAllowed({
|
||||
allowFromLower: params.allowFromLower,
|
||||
@@ -250,9 +291,16 @@ export async function resolveSlackThreadContextData(params: {
|
||||
const historyParts: string[] = [];
|
||||
for (const historyMsg of filteredThreadHistory) {
|
||||
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
|
||||
const isBot = Boolean(historyMsg.botId);
|
||||
const msgSenderName = msgUser?.name ?? (isBot ? `Bot (${historyMsg.botId})` : "Unknown");
|
||||
const role = isBot ? "assistant" : "user";
|
||||
const isOtherBot = Boolean(historyMsg.botId) && historyMsg.botId !== params.ctx.botId;
|
||||
const isCurrentBot = isCurrentBotAuthor({
|
||||
userId: historyMsg.userId,
|
||||
botId: historyMsg.botId,
|
||||
});
|
||||
const isAssistantRole = isCurrentBot || isOtherBot || Boolean(historyMsg.botId);
|
||||
const role = isAssistantRole ? "assistant" : "user";
|
||||
const msgSenderName = isCurrentBot
|
||||
? "Bot (this assistant)"
|
||||
: (msgUser?.name ?? (historyMsg.botId ? `Bot (${historyMsg.botId})` : "Unknown"));
|
||||
const msgWithId = `${historyMsg.text}\n[slack message id: ${historyMsg.ts ?? "unknown"} channel: ${params.message.channel}]`;
|
||||
historyParts.push(
|
||||
formatInboundEnvelope({
|
||||
|
||||
Reference in New Issue
Block a user