fix(slack): improve interactive reply parity (#53389)

* fix(slack): improve interactive reply parity

* fix(slack): isolate reply interactions from plugins

* docs(changelog): note slack interactive parity fixes

* fix(slack): preserve preview text for local agent replies

* fix(agent): preserve directive text in local previews
This commit is contained in:
Vincent Koc
2026-03-24 10:23:10 -07:00
committed by GitHub
parent 398d58fb8a
commit f2475a7f70
17 changed files with 763 additions and 28 deletions

View File

@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { slackOutbound } from "../../../test/channel-outbounds.js";
import type { CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { deliverAgentCommandResult, normalizeAgentCommandReplyPayloads } from "./delivery.js";
import type { AgentCommandOpts } from "./types.js";
type NormalizeParams = Parameters<typeof normalizeAgentCommandReplyPayloads>[0];
type RunResult = NormalizeParams["result"];
const emptyRegistry = createTestRegistry([]);
const slackRegistry = createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: createOutboundTestPlugin({
id: "slack",
outbound: slackOutbound,
messaging: {
enableInteractiveReplies: ({ cfg }) =>
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
?.capabilities?.interactiveReplies === true,
},
}),
},
]);
function createResult(overrides: Partial<RunResult> = {}): RunResult {
return {
meta: {
durationMs: 1,
...overrides.meta,
},
...(overrides.payloads ? { payloads: overrides.payloads } : {}),
} as RunResult;
}
describe("normalizeAgentCommandReplyPayloads", () => {
beforeEach(() => {
setActivePluginRegistry(slackRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("compiles Slack directives for direct agent deliveries when interactive replies are enabled", () => {
const normalized = normalizeAgentCommandReplyPayloads({
cfg: {
channels: {
slack: {
capabilities: { interactiveReplies: true },
},
},
} as OpenClawConfig,
opts: { message: "test" } as AgentCommandOpts,
outboundSession: undefined,
deliveryChannel: "slack",
payloads: [{ text: "Choose [[slack_buttons: Retry:retry]]" }],
result: createResult(),
});
expect(normalized).toMatchObject([
{
text: "Choose",
interactive: {
blocks: [
{
type: "text",
text: "Choose",
},
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
],
},
},
]);
});
it("renders response prefix templates with the selected runtime model", () => {
const normalized = normalizeAgentCommandReplyPayloads({
cfg: {
messages: {
responsePrefix: "[{modelFull}]",
},
} as OpenClawConfig,
opts: { message: "test" } as AgentCommandOpts,
outboundSession: undefined,
deliveryChannel: "slack",
payloads: [{ text: "Ready." }],
result: createResult({
meta: {
durationMs: 1,
agentMeta: {
sessionId: "session-1",
provider: "openai-codex",
model: "gpt-5.4",
},
},
}),
});
expect(normalized).toMatchObject([
{
text: "[openai-codex/gpt-5.4] Ready.",
},
]);
});
it("keeps Slack options text intact for local preview when delivery is disabled", async () => {
const runtime = {
log: vi.fn(),
};
const delivered = await deliverAgentCommandResult({
cfg: {
channels: {
slack: {
capabilities: { interactiveReplies: true },
},
},
} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "test",
channel: "slack",
} as AgentCommandOpts,
outboundSession: undefined,
sessionEntry: undefined,
payloads: [{ text: "Options: on, off." }],
result: createResult(),
});
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith("Options: on, off.");
expect(delivered.payloads).toMatchObject([{ text: "Options: on, off." }]);
});
it("keeps LINE directive-only replies intact for local preview when delivery is disabled", async () => {
const runtime = {
log: vi.fn(),
};
const delivered = await deliverAgentCommandResult({
cfg: {} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "test",
channel: "line",
} as AgentCommandOpts,
outboundSession: undefined,
sessionEntry: undefined,
payloads: [
{
text: "[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
},
],
result: createResult(),
});
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith(
"[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
);
expect(delivered.payloads).toMatchObject([
{
text: "[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
},
]);
});
});

View File

@@ -1,4 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -62,6 +66,68 @@ function logNestedOutput(
}
}
export function normalizeAgentCommandReplyPayloads(params: {
cfg: OpenClawConfig;
opts: AgentCommandOpts;
outboundSession: OutboundSessionContext | undefined;
payloads: RunResult["payloads"];
result: RunResult;
deliveryChannel?: string;
accountId?: string;
applyChannelTransforms?: boolean;
}): ReplyPayload[] {
const payloads = params.payloads ?? [];
if (payloads.length === 0) {
return [];
}
const channel =
params.deliveryChannel && !isInternalMessageChannel(params.deliveryChannel)
? (normalizeChannelId(params.deliveryChannel) ?? params.deliveryChannel)
: undefined;
if (!channel) {
return payloads as ReplyPayload[];
}
const sessionKey = params.outboundSession?.key ?? params.opts.sessionKey;
const agentId =
params.outboundSession?.agentId ??
resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const replyPrefix = createReplyPrefixContext({
cfg: params.cfg,
agentId,
channel,
accountId: params.accountId,
});
const modelUsed = params.result.meta.agentMeta?.model;
const providerUsed = params.result.meta.agentMeta?.provider;
if (providerUsed && modelUsed) {
replyPrefix.onModelSelected({
provider: providerUsed,
model: modelUsed,
thinkLevel: undefined,
});
}
const responsePrefixContext = replyPrefix.responsePrefixContextProvider();
const applyChannelTransforms = params.applyChannelTransforms ?? true;
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of payloads) {
const normalized = normalizeReplyPayload(payload as ReplyPayload, {
responsePrefix: replyPrefix.responsePrefix,
enableSlackInteractiveReplies: replyPrefix.enableSlackInteractiveReplies,
applyChannelTransforms,
responsePrefixContext,
});
if (normalized) {
normalizedPayloads.push(normalized);
}
}
return normalizedPayloads;
}
export async function deliverAgentCommandResult(params: {
cfg: OpenClawConfig;
deps: CliDeps;
@@ -173,7 +239,17 @@ export async function deliverAgentCommandResult(params: {
}
}
const normalizedPayloads = normalizeOutboundPayloadsForJson(payloads ?? []);
const normalizedReplyPayloads = normalizeAgentCommandReplyPayloads({
cfg,
opts,
outboundSession,
payloads,
result,
deliveryChannel,
accountId: resolvedAccountId,
applyChannelTransforms: deliver,
});
const normalizedPayloads = normalizeOutboundPayloadsForJson(normalizedReplyPayloads);
if (opts.json) {
runtime.log(
JSON.stringify(
@@ -195,7 +271,7 @@ export async function deliverAgentCommandResult(params: {
return { payloads: [], meta: result.meta };
}
const deliveryPayloads = normalizeOutboundPayloads(payloads);
const deliveryPayloads = normalizeOutboundPayloads(normalizedReplyPayloads);
const logPayload = (payload: NormalizedOutboundPayload) => {
if (opts.json) {
return;