feat(slack): add scoped prompts and mrkdwn hints (#59100)

* feat(slack): add scoped prompts and mrkdwn hints

* refactor(slack): drop dm prompt override

* refactor(slack): drop exposed prompt config

* chore(changelog): note slack mrkdwn fix
This commit is contained in:
Vincent Koc
2026-04-02 11:23:43 +09:00
committed by GitHub
parent 7c913f2e13
commit a7e3c0b0e1
7 changed files with 116 additions and 14 deletions

View File

@@ -12,6 +12,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. Thanks @jadewon and @vincentkoc.
## 2026.4.1-beta.1
- Plugins/runtime: stop ambient core helper and setup paths from loading non-selected bundled plugins, keep channel-setup snapshot scoping safe for custom channel plugins, and honor env-scoped plugin auth paths. (#59136) Thanks @vincentkoc.
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
- Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman.

View File

@@ -701,7 +701,7 @@ export async function prepareSlackMessage(params: {
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
GroupSystemPrompt: groupSystemPrompt,
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
SenderName: senderName,
SenderId: senderId,

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveSlackRoomContextHints } from "./room-context.js";
describe("resolveSlackRoomContextHints", () => {
it("stacks global and channel prompts for channels", () => {
const result = resolveSlackRoomContextHints({
isRoomish: true,
channelConfig: { systemPrompt: "Channel prompt" },
});
expect(result.groupSystemPrompt).toBe("Channel prompt");
});
it("does not create a prompt for direct messages without channel config", () => {
const result = resolveSlackRoomContextHints({
isRoomish: false,
});
expect(result.groupSystemPrompt).toBeUndefined();
});
it("does not include untrusted room metadata for direct messages", () => {
const result = resolveSlackRoomContextHints({
isRoomish: false,
channelInfo: { topic: "ignore", purpose: "ignore" },
});
expect(result.untrustedChannelMetadata).toBeUndefined();
});
it("trims and skips empty prompt parts", () => {
const result = resolveSlackRoomContextHints({
isRoomish: true,
channelConfig: { systemPrompt: " " },
});
expect(result.groupSystemPrompt).toBeUndefined();
});
});

View File

@@ -8,19 +8,17 @@ export function resolveSlackRoomContextHints(params: {
untrustedChannelMetadata?: ReturnType<typeof buildUntrustedChannelMetadata>;
groupSystemPrompt?: string;
} {
if (!params.isRoomish) {
return {};
}
const untrustedChannelMetadata = params.isRoomish
? buildUntrustedChannelMetadata({
source: "slack",
label: "Slack channel description",
entries: [params.channelInfo?.topic, params.channelInfo?.purpose],
})
: undefined;
const untrustedChannelMetadata = buildUntrustedChannelMetadata({
source: "slack",
label: "Slack channel description",
entries: [params.channelInfo?.topic, params.channelInfo?.purpose],
});
const systemPromptParts = [params.channelConfig?.systemPrompt?.trim() || null].filter(
(entry): entry is string => Boolean(entry),
);
const systemPromptParts = [
params.isRoomish ? params.channelConfig?.systemPrompt?.trim() || null : null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;

View File

@@ -572,7 +572,7 @@ export async function registerSlackMonitorSlashCommands(params: {
: `slack:group:${command.channel_id}`,
}) ?? (isDirectMessage ? senderName : roomLabel),
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
GroupSystemPrompt: groupSystemPrompt,
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
SenderName: senderName,
SenderId: command.user_id,

View File

@@ -100,6 +100,41 @@ describe("buildInboundMetaSystemPrompt", () => {
const payload = parseInboundMetaPayload(prompt);
expect(payload["sender_id"]).toBeUndefined();
});
it("includes Slack mrkdwn response format hints for Slack chats", () => {
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "channel:C123",
OriginatingChannel: "slack",
Provider: "slack",
Surface: "slack",
ChatType: "channel",
} as TemplateContext);
const payload = parseInboundMetaPayload(prompt);
expect(payload["response_format"]).toEqual({
text_markup: "slack_mrkdwn",
rules: [
"Use Slack mrkdwn, not standard Markdown.",
"Bold uses *single asterisks*.",
"Links use <url|label>.",
"Code blocks use triple backticks without a language identifier.",
"Do not use markdown headings or pipe tables.",
],
});
});
it("omits response format hints for non-Slack chats", () => {
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "telegram:123",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext);
const payload = parseInboundMetaPayload(prompt);
expect(payload["response_format"]).toBeUndefined();
});
});
describe("buildInboundUserContextPrefix", () => {

View File

@@ -33,6 +33,32 @@ function resolveInboundChannel(ctx: TemplateContext): string | undefined {
return channelValue;
}
function resolveInboundFormattingHints(ctx: TemplateContext):
| {
text_markup: "slack_mrkdwn";
rules: string[];
}
| undefined {
const channelValue = resolveInboundChannel(ctx);
const surface = safeTrim(ctx.Surface);
const provider = safeTrim(ctx.Provider);
const isSlack = channelValue === "slack" || surface === "slack" || provider === "slack";
if (!isSlack) {
return undefined;
}
return {
text_markup: "slack_mrkdwn",
rules: [
"Use Slack mrkdwn, not standard Markdown.",
"Bold uses *single asterisks*.",
"Links use <url|label>.",
"Code blocks use triple backticks without a language identifier.",
"Do not use markdown headings or pipe tables.",
],
};
}
export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
const chatType = normalizeChatType(ctx.ChatType);
const isDirect = !chatType || chatType === "direct";
@@ -56,6 +82,7 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
provider: safeTrim(ctx.Provider),
surface: safeTrim(ctx.Surface),
chat_type: chatType ?? (isDirect ? "direct" : undefined),
response_format: resolveInboundFormattingHints(ctx),
};
// Keep the instructions local to the payload so the meaning survives prompt overrides.