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

@@ -13,13 +13,14 @@ import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
import { compileSlackInteractiveReplies } from "./slack-directives.js";
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
export type NormalizeReplyOptions = {
responsePrefix?: string;
enableSlackInteractiveReplies?: boolean;
applyChannelTransforms?: boolean;
/** Context for template variable interpolation in responsePrefix */
responsePrefixContext?: ResponsePrefixContext;
onHeartbeatStrip?: () => void;
@@ -32,6 +33,7 @@ export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const applyChannelTransforms = opts.applyChannelTransforms ?? true;
const hasContent = (text: string | undefined) =>
hasReplyPayloadContent(
{
@@ -95,7 +97,7 @@ export function normalizeReplyPayload(
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text };
if (text && hasLineDirectives(text)) {
if (applyChannelTransforms && text && hasLineDirectives(text)) {
enrichedPayload = parseLineDirectives(enrichedPayload);
text = enrichedPayload.text;
}
@@ -115,8 +117,8 @@ export function normalizeReplyPayload(
}
enrichedPayload = { ...enrichedPayload, text };
if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) {
enrichedPayload = parseSlackDirectives(enrichedPayload);
if (applyChannelTransforms && opts.enableSlackInteractiveReplies && text) {
enrichedPayload = compileSlackInteractiveReplies(enrichedPayload);
}
return enrichedPayload;

View File

@@ -193,6 +193,81 @@ describe("normalizeReplyPayload", () => {
],
});
});
it("compiles simple trailing Options lines into Slack buttons when interactive replies are enabled", () => {
const result = normalizeReplyPayload(
{
text: "Current verbose level: off.\nOptions: on, full, off.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe("Current verbose level: off.");
expect(result!.interactive).toEqual({
blocks: [
{
type: "text",
text: "Current verbose level: off.",
},
{
type: "buttons",
buttons: [
{ label: "on", value: "on" },
{ label: "full", value: "full" },
{ label: "off", value: "off" },
],
},
],
});
});
it("uses a Slack select when simple Options lines exceed the button row size", () => {
const result = normalizeReplyPayload(
{
text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe("Choose a reasoning level.");
expect(result!.interactive).toEqual({
blocks: [
{
type: "text",
text: "Choose a reasoning level.",
},
{
type: "select",
placeholder: "Choose an option",
options: [
{ label: "off", value: "off" },
{ label: "minimal", value: "minimal" },
{ label: "low", value: "low" },
{ label: "medium", value: "medium" },
{ label: "high", value: "high" },
{ label: "adaptive", value: "adaptive" },
],
},
],
});
});
it("leaves complex Options lines as plain text", () => {
const result = normalizeReplyPayload(
{
text: "ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe(
"ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
);
expect(result!.interactive).toBeUndefined();
});
});
describe("typing controller", () => {

View File

@@ -3,6 +3,9 @@ import type { ReplyPayload } from "../types.js";
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i;
const SLACK_AUTO_SELECT_MAX_ITEMS = 12;
const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i;
type SlackChoice = {
label: string;
@@ -147,3 +150,97 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
},
};
}
function hasSlackBlocks(payload: ReplyPayload): boolean {
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
if (typeof blocks === "string") {
return blocks.trim().length > 0;
}
return Array.isArray(blocks) && blocks.length > 0;
}
function parseSimpleSlackOptions(raw: string): SlackChoice[] | null {
const entries = raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) {
return null;
}
if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) {
return null;
}
const deduped = new Set(entries.map((entry) => entry.toLowerCase()));
if (deduped.size !== entries.length) {
return null;
}
return entries.map((entry) => ({
label: entry,
value: entry,
}));
}
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {
return payload;
}
const lines = text.split("\n");
const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim());
if (lastNonEmptyIndex == null) {
return payload;
}
const optionsLine = lines[lastNonEmptyIndex] ?? "";
const match = optionsLine.match(SLACK_OPTIONS_LINE_RE);
if (!match) {
return payload;
}
const choices = parseSimpleSlackOptions(match[1] ?? "");
if (!choices) {
return payload;
}
const bodyText = lines
.filter((_, index) => index !== lastNonEmptyIndex)
.join("\n")
.trim();
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const bodyBlock = buildTextBlock(bodyText);
if (bodyBlock) {
generatedBlocks.push(bodyBlock);
}
generatedBlocks.push(
choices.length <= SLACK_BUTTON_MAX_ITEMS
? {
type: "buttons",
buttons: choices,
}
: {
type: "select",
placeholder: "Choose an option",
options: choices,
},
);
return {
...payload,
text: bodyText || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
if (hasSlackDirectives(text)) {
return parseSlackDirectives(payload);
}
return parseSlackOptionsLine(payload);
}