mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 01:40:23 +00:00
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:
177
src/agents/command/delivery.test.ts
Normal file
177
src/agents/command/delivery.test.ts
Normal 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]]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user