Slack: support Block Kit payloads in agent replies (#44592)

* Slack: route reply blocks through outbound adapter

* Slack: cover Block Kit outbound payloads

* Changelog: add Slack Block Kit agent reply entry
This commit is contained in:
Vincent Koc
2026-03-12 23:18:59 -04:00
committed by GitHub
parent 433e65711f
commit 42efd98ff8
3 changed files with 94 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.
- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
### Fixes

View File

@@ -1,4 +1,4 @@
import { describe, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import {
installSendPayloadContractSuite,
@@ -38,4 +38,66 @@ describe("slackOutbound sendPayload", () => {
chunking: { mode: "passthrough", longTextLength: 5000 },
createHarness,
});
it("forwards Slack blocks from channelData", async () => {
const { run, sendMock, to } = createHarness({
payload: {
text: "Fallback summary",
channelData: {
slack: {
blocks: [{ type: "divider" }],
},
},
},
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(
to,
"Fallback summary",
expect.objectContaining({
blocks: [{ type: "divider" }],
}),
);
expect(result).toMatchObject({ channel: "slack", messageId: "sl-1" });
});
it("accepts blocks encoded as JSON strings in Slack channelData", async () => {
const { run, sendMock, to } = createHarness({
payload: {
channelData: {
slack: {
blocks: '[{"type":"section","text":{"type":"mrkdwn","text":"hello"}}]',
},
},
},
});
await run();
expect(sendMock).toHaveBeenCalledWith(
to,
"",
expect.objectContaining({
blocks: [{ type: "section", text: { type: "mrkdwn", text: "hello" } }],
}),
);
});
it("rejects invalid Slack blocks from channelData", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: {},
},
},
},
});
await expect(run()).rejects.toThrow(/blocks must be an array/i);
expect(sendMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { parseSlackBlocksInput } from "../../../slack/blocks-input.js";
import { sendMessageSlack, type SlackSendIdentity } from "../../../slack/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { sendTextMediaPayload } from "./direct-text-media.js";
@@ -53,6 +54,7 @@ async function sendSlackOutboundMessage(params: {
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
blocks?: Parameters<typeof sendMessageSlack>[2]["blocks"];
accountId?: string | null;
deps?: { sendSlack?: typeof sendMessageSlack } | null;
replyToId?: string | null;
@@ -87,17 +89,43 @@ async function sendSlackOutboundMessage(params: {
...(params.mediaUrl
? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots }
: {}),
...(params.blocks ? { blocks: params.blocks } : {}),
...(slackIdentity ? { identity: slackIdentity } : {}),
});
return { channel: "slack" as const, ...result };
}
function resolveSlackBlocks(channelData: Record<string, unknown> | undefined) {
const slackData = channelData?.slack;
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
return undefined;
}
return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks);
}
export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }),
sendPayload: async (ctx) => {
const blocks = resolveSlackBlocks(ctx.payload.channelData);
if (!blocks) {
return await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound });
}
return await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.payload.text ?? "",
mediaUrl: ctx.payload.mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
});
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => {
return await sendSlackOutboundMessage({
cfg,