refactor: deprecate legacy interactive reply APIs

This commit is contained in:
Peter Steinberger
2026-05-17 11:59:14 +01:00
parent ad861d4c9d
commit ee72ce8cf7
29 changed files with 493 additions and 86 deletions

View File

@@ -1,2 +1,2 @@
ec9fa02c3af9c210f7dbf6157d2f18e5c7171c29e6ae13b4f539aefcb25d178a plugin-sdk-api-baseline.json
2ee394b924edb9843987710a65f9d45523efadd7e7940e01c88ea39dcdcdad7c plugin-sdk-api-baseline.jsonl
df6c2799805dc3c57924dbb1632d11e7ed08ef4d7759f535998b170f1a10a638 plugin-sdk-api-baseline.json
e3526669b79e5eaa3b92e03bece552402209d3cf5b35343c33b62299f71b2efc plugin-sdk-api-baseline.jsonl

View File

@@ -1245,6 +1245,16 @@ back through the existing Slack interaction event path. Keep them for old
prompts and Slack-specific escape hatches; use shared presentation for new
portable controls.
The directive compiler APIs are also deprecated for new producer code:
- `compileSlackInteractiveReplies(...)`
- `parseSlackOptionsLine(...)`
- `isSlackInteractiveRepliesEnabled(...)`
- `buildSlackInteractiveBlocks(...)`
Use `presentation` payloads and `buildSlackPresentationBlocks(...)` for new
Slack-rendered controls.
Notes:
- This is Slack-specific legacy UI. Other channels do not translate Slack Block

View File

@@ -391,12 +391,33 @@ New code should accept or produce `MessagePresentation` directly. Existing
`interactive` payloads are a deprecated subset of `presentation`; runtime
support remains for older producers.
`presentationToInteractiveReply(...)` preserves visible presentation text by
mapping the title, text, context, buttons, and selects into the older
`InteractiveReply` shape. Component renderers that already draw title, text,
context, and divider blocks natively should use
`presentationToInteractiveControlsReply(...)` instead, then append only the
button and select controls.
The legacy `InteractiveReply*` types and conversion helpers are marked
`@deprecated` in the SDK:
- `InteractiveReply`, `InteractiveReplyBlock`, `InteractiveReplyButton`,
`InteractiveReplyOption`, `InteractiveReplySelectBlock`, and
`InteractiveReplyTextBlock`
- `normalizeInteractiveReply(...)`
- `hasInteractiveReplyBlocks(...)`
- `interactiveReplyToPresentation(...)`
- `presentationToInteractiveReply(...)`
- `presentationToInteractiveControlsReply(...)`
- `resolveInteractiveTextFallback(...)`
- `reduceInteractiveReply(...)`
`presentationToInteractiveReply(...)` and
`presentationToInteractiveControlsReply(...)` remain available as renderer
bridges for legacy channel implementations. New producer code should not call
them; send `presentation` and let core/channel adaptation handle rendering.
Approval helpers also have presentation-first replacements:
- use `buildApprovalPresentationFromActionDescriptors(...)` instead of
`buildApprovalInteractiveReplyFromActionDescriptors(...)`
- use `buildApprovalPresentation(...)` instead of
`buildApprovalInteractiveReply(...)`
- use `buildExecApprovalPresentation(...)` instead of
`buildExecApprovalInteractiveReply(...)`
`renderMessagePresentationFallbackText(...)` returns an empty string for
presentation blocks that have no text fallback, such as a divider-only

View File

@@ -8,7 +8,7 @@ import type {
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalInteractiveReplyFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { ExecApprovalRequest } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logError } from "openclaw/plugin-sdk/logging-core";
@@ -142,7 +142,7 @@ function buildSlackPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBl
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
interactive: buildApprovalInteractiveReplyFromActionDescriptors(view.actions),
presentation: buildApprovalPresentationFromActionDescriptors(view.actions),
}) ?? [];
return [
{

View File

@@ -85,6 +85,9 @@ export function resolveSlackInteractiveBlockOffsets(
return { buttonIndexOffset, selectIndexOffset };
}
/**
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
*/
export function buildSlackInteractiveBlocks(
interactive?: InteractiveReply,
options: SlackInteractiveBlockRenderOptions = {},

View File

@@ -162,6 +162,9 @@ function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boole
return false;
}
/**
* @deprecated Only needed for legacy Slack reply directives. New producers should emit presentation payloads.
*/
export function isSlackInteractiveRepliesEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -173,6 +176,9 @@ export function isSlackInteractiveRepliesEnabled(params: {
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}
/**
* @deprecated Slack reply directives are legacy. New producers should emit presentation payloads.
*/
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
@@ -230,6 +236,9 @@ export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayl
};
}
/**
* @deprecated Legacy Slack directive fallback. New producers should emit presentation payloads.
*/
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {

View File

@@ -1,16 +1,28 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { parseSlackBlocksInput, SLACK_MAX_BLOCKS } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import {
buildSlackInteractiveBlocks,
buildSlackPresentationBlocks,
resolveSlackInteractiveBlockOffsets,
type SlackBlock,
} from "./blocks-render.js";
export function resolveSlackReplyBlocks(payload: ReplyPayload): SlackBlock[] | undefined {
const slackData = payload.channelData?.slack;
const interactiveBlocks = buildSlackInteractiveBlocks(payload.interactive);
let channelBlocks: SlackBlock[] = [];
if (slackData && typeof slackData === "object" && !Array.isArray(slackData)) {
channelBlocks =
(parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as SlackBlock[]) ?? [];
}
const blocks = [...channelBlocks, ...interactiveBlocks];
const presentationBlocks = buildSlackPresentationBlocks(
payload.presentation,
resolveSlackInteractiveBlockOffsets(channelBlocks),
);
const interactiveBlocks = buildSlackInteractiveBlocks(
payload.interactive,
resolveSlackInteractiveBlockOffsets([...channelBlocks, ...presentationBlocks]),
);
const blocks = [...channelBlocks, ...presentationBlocks, ...interactiveBlocks];
if (blocks.length > SLACK_MAX_BLOCKS) {
throw new Error(
`Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`,

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks } from "./blocks-render.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
describe("buildSlackInteractiveBlocks", () => {
it("renders shared interactive blocks in authored order", () => {
@@ -306,3 +307,87 @@ describe("buildSlackInteractiveBlocks", () => {
expect(buttonBlock.elements?.[3]).not.toHaveProperty("style");
});
});
describe("buildSlackPresentationBlocks", () => {
it("renders presentation controls without requiring legacy interactive payloads", () => {
const blocks = buildSlackPresentationBlocks({
blocks: [
{ type: "text", text: "Pick" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success" }],
},
],
});
expect(blocks).toEqual([
{
type: "section",
text: { type: "mrkdwn", text: "Pick" },
},
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
elements: [
{
type: "button",
action_id: "openclaw:reply_button:1:1",
text: {
type: "plain_text",
text: "Approve",
emoji: true,
},
value: "approve",
style: "primary",
},
],
},
]);
});
});
describe("resolveSlackReplyBlocks", () => {
it("offsets legacy interactive blocks after channel and presentation controls", () => {
const blocks = resolveSlackReplyBlocks({
channelData: {
slack: {
blocks: [
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
elements: [],
},
],
},
},
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Stage", value: "stage" }],
},
],
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
});
const presentationButtonBlock = blocks?.[1] as
| { elements?: Array<{ action_id?: string }> }
| undefined;
const legacyButtonBlock = blocks?.[2] as
| { elements?: Array<{ action_id?: string }> }
| undefined;
expect(blocks?.[0]?.block_id).toBe("openclaw_reply_buttons_1");
expect(blocks?.[1]?.block_id).toBe("openclaw_reply_buttons_2");
expect(presentationButtonBlock?.elements?.[0]?.action_id).toBe("openclaw:reply_button:2:1");
expect(blocks?.[2]?.block_id).toBe("openclaw_reply_buttons_3");
expect(legacyButtonBlock?.elements?.[0]?.action_id).toBe("openclaw:reply_button:3:1");
});
});

View File

@@ -13,7 +13,6 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
@@ -138,7 +137,8 @@ function resolveTelegramButtonsFromParams(
presentation = normalizeMessagePresentation(params.presentation),
) {
return resolveTelegramInlineButtons({
interactive: presentation ? presentationToInteractiveReply(presentation) : params.interactive,
presentation,
interactive: params.interactive,
});
}

View File

@@ -6,7 +6,7 @@ import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/a
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
buildApprovalInteractiveReplyFromActionDescriptors,
buildApprovalPresentationFromActionDescriptors,
buildExecApprovalPendingReplyPayload,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { ExecApprovalPendingReplyParams } from "openclaw/plugin-sdk/approval-reply-runtime";
@@ -92,7 +92,7 @@ function buildPendingPayload(params: {
return {
text: payload.text ?? "",
buttons: resolveTelegramInlineButtons({
interactive: buildApprovalInteractiveReplyFromActionDescriptors(params.view.actions),
presentation: buildApprovalPresentationFromActionDescriptors(params.view.actions),
}),
};
}

View File

@@ -36,10 +36,7 @@ import type {
} from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
@@ -145,12 +142,10 @@ function resolvePayloadTelegramInlineButtons(
| { buttons?: TelegramInlineButtons }
| undefined;
const presentation = normalizeMessagePresentation(payload.presentation);
const interactive =
payload.interactive ??
(presentation ? presentationToInteractiveReply(presentation) : undefined);
return resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
presentation,
interactive: payload.interactive,
});
}

View File

@@ -543,6 +543,37 @@ describe("registerTelegramNativeCommands", () => {
expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png");
});
it("falls back to a normal reply when a progress result has presentation controls", async () => {
const presentation = {
blocks: [
{
kind: "actions",
buttons: [{ label: "Approve", action: { type: "command", value: "/approve yes" } }],
},
],
};
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Approval required",
presentation,
},
});
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(replyAt(firstDeliverRepliesParams())).toMatchObject({
text: "Approval required",
presentation,
});
});
it("cleans up the progress placeholder before falling back after an edit failure", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",

View File

@@ -333,6 +333,7 @@ function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): b
result.text.trim() &&
!result.mediaUrl &&
(!result.mediaUrls || result.mediaUrls.length === 0) &&
!result.presentation &&
!result.interactive &&
!result.btw &&
telegramData?.pin !== true,

View File

@@ -10,10 +10,7 @@ import {
toPluginMessageSentEvent,
} from "openclaw/plugin-sdk/hook-runtime";
import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import {
buildOutboundMediaLoadOptions,
isGifMedia,
@@ -773,9 +770,7 @@ export async function deliverReplies(params: {
: [];
const hasMedia = mediaList.length > 0;
const presentation = normalizeMessagePresentation(reply?.presentation);
const interactive =
reply?.interactive ??
(presentation ? presentationToInteractiveReply(presentation) : undefined);
const interactive = reply?.interactive;
const resolvedReplyText =
resolveTelegramInteractiveTextFallback({
text: reply?.text,
@@ -850,6 +845,7 @@ export async function deliverReplies(params: {
const replyMarkup = buildInlineKeyboard(
resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
presentation,
interactive,
}),
);

View File

@@ -81,5 +81,28 @@ export function describeTelegramInteractiveButtonBehavior(): void {
],
]);
});
it("prefers legacy interactive buttons over generic presentation buttons", () => {
expect(
resolveTelegramInlineButtons({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Generic", value: "generic" }],
},
],
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Legacy", value: "legacy" }],
},
],
},
}),
).toEqual([[{ text: "Legacy", callback_data: "legacy", style: undefined }]]);
});
});
}

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import {
buildTelegramInteractiveButtons,
buildTelegramPresentationButtons,
} from "./button-types.js";
import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js";
describeTelegramInteractiveButtonBehavior();
@@ -21,3 +24,27 @@ describe("buildTelegramInteractiveButtons callback limits", () => {
).toEqual([[{ text: "Keep", callback_data: "ok", style: undefined }]]);
});
});
describe("buildTelegramPresentationButtons", () => {
it("builds inline buttons from presentation blocks", () => {
expect(
buildTelegramPresentationButtons({
blocks: [
{ type: "text", text: "Choose" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "/approve req-1 allow-once", style: "success" }],
},
],
}),
).toEqual([
[
{
text: "Approve",
callback_data: "/approve req-1 allow-once",
style: "success",
},
],
]);
});
});

View File

@@ -1,8 +1,11 @@
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
isMessagePresentationInteractiveBlock,
normalizeMessagePresentation,
normalizeInteractiveReply,
type InteractiveReply,
type InteractiveReplyButton,
type MessagePresentation,
type MessagePresentationButton,
} from "openclaw/plugin-sdk/interactive-runtime";
import { sanitizeTelegramCallbackData } from "./approval-callback-data.js";
@@ -21,12 +24,14 @@ export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineBu
const TELEGRAM_INTERACTIVE_ROW_SIZE = 3;
function toTelegramButtonStyle(
style?: InteractiveReplyButton["style"],
style?: MessagePresentationButton["style"],
): TelegramInlineButton["style"] {
return style === "danger" || style === "success" || style === "primary" ? style : undefined;
}
function toTelegramInlineButton(button: InteractiveReplyButton): TelegramInlineButton | undefined {
function toTelegramInlineButton(
button: MessagePresentationButton,
): TelegramInlineButton | undefined {
const style = toTelegramButtonStyle(button.style);
if (button.url) {
return {
@@ -54,7 +59,7 @@ function toTelegramInlineButton(button: InteractiveReplyButton): TelegramInlineB
}
function chunkInteractiveButtons(
buttons: readonly InteractiveReplyButton[],
buttons: readonly MessagePresentationButton[],
rows: TelegramInlineButton[][],
) {
for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) {
@@ -94,11 +99,37 @@ export function buildTelegramInteractiveButtons(
return rows.length > 0 ? rows : undefined;
}
export function buildTelegramPresentationButtons(
presentation?: MessagePresentation,
): TelegramInlineButtons | undefined {
const rows: TelegramInlineButton[][] = [];
for (const block of presentation?.blocks ?? []) {
if (!isMessagePresentationInteractiveBlock(block)) {
continue;
}
if (block.type === "buttons") {
chunkInteractiveButtons(block.buttons, rows);
continue;
}
chunkInteractiveButtons(
block.options.map((option) => ({
label: option.label,
value: option.value,
})),
rows,
);
}
return rows.length > 0 ? rows : undefined;
}
export function resolveTelegramInlineButtons(params: {
buttons?: TelegramInlineButtons;
presentation?: unknown;
interactive?: unknown;
}): TelegramInlineButtons | undefined {
return (
params.buttons ?? buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive))
params.buttons ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ??
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
);
}

View File

@@ -216,6 +216,32 @@ describe("telegramOutbound", () => {
]);
});
it("preserves explicit Telegram buttons when rendering presentation payloads", async () => {
const rendered = await telegramOutbound.renderPresentation?.({
payload: {
text: "Use native buttons:",
channelData: {
telegram: {
buttons: [[{ text: "Native", callback_data: "native" }]],
},
},
},
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Generic", value: "generic" }],
},
],
},
ctx: {} as never,
});
expect((rendered?.channelData?.telegram as { buttons?: unknown })?.buttons).toEqual([
[{ text: "Native", callback_data: "native" }],
]);
});
it("lets allow-always approval callbacks reach Telegram's callback rewrite", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({
messageId: "tg-approval",

View File

@@ -5,7 +5,6 @@ import {
} from "openclaw/plugin-sdk/channel-send-result";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/outbound-runtime";
@@ -118,19 +117,17 @@ export async function sendTelegramPayloadMessages(params: {
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const presentation = normalizeMessagePresentation(params.payload.presentation);
const interactive =
params.payload.interactive ??
(presentation ? presentationToInteractiveReply(presentation) : undefined);
const text =
resolveTelegramInteractiveTextFallback({
text: params.payload.text,
interactive,
interactive: params.payload.interactive,
presentation,
}) ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const buttons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
presentation,
interactive: params.payload.interactive,
});
const payloadOpts = {
...params.baseOpts,
@@ -213,11 +210,24 @@ export function createTelegramOutboundAdapter(
batch: true,
},
},
renderPresentation: ({ payload, presentation }) => ({
...payload,
text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
interactive: presentationToInteractiveReply(presentation),
}),
renderPresentation: ({ payload, presentation }) => {
const telegramData = payload.channelData?.telegram as Record<string, unknown> | undefined;
const hasExplicitButtons = telegramData && "buttons" in telegramData;
const buttons = hasExplicitButtons
? undefined
: resolveTelegramInlineButtons({ presentation });
return {
...payload,
text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
channelData: {
...payload.channelData,
telegram: {
...telegramData,
...(buttons ? { buttons } : {}),
},
},
};
},
pinDeliveredMessage: async ({ cfg, target, messageId, pin }) => {
const { pinMessageTelegram } = await loadSendModule();
await pinMessageTelegram(target.to, messageId, {

View File

@@ -82,18 +82,31 @@ export type AgentRuntimeProviderHandle = {
export type AgentRuntimeInteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
export type AgentRuntimeInteractiveReplyButton = {
export type AgentRuntimeMessagePresentationButton = {
label: string;
value?: string;
url?: string;
style?: AgentRuntimeInteractiveButtonStyle;
};
export type AgentRuntimeInteractiveReplyOption = {
export type AgentRuntimeMessagePresentationOption = {
label: string;
value: string;
};
/**
* @deprecated Use AgentRuntimeMessagePresentationButton.
*/
export type AgentRuntimeInteractiveReplyButton = AgentRuntimeMessagePresentationButton;
/**
* @deprecated Use AgentRuntimeMessagePresentationOption.
*/
export type AgentRuntimeInteractiveReplyOption = AgentRuntimeMessagePresentationOption;
/**
* @deprecated Use AgentRuntimeMessagePresentationBlock.
*/
export type AgentRuntimeInteractiveReplyBlock =
| {
type: "text";
@@ -109,6 +122,9 @@ export type AgentRuntimeInteractiveReplyBlock =
options: AgentRuntimeInteractiveReplyOption[];
};
/**
* @deprecated Use AgentRuntimeMessagePresentation.
*/
export type AgentRuntimeInteractiveReply = {
blocks: AgentRuntimeInteractiveReplyBlock[];
};
@@ -134,12 +150,12 @@ export type AgentRuntimeMessagePresentationBlock =
}
| {
type: "buttons";
buttons: AgentRuntimeInteractiveReplyButton[];
buttons: AgentRuntimeMessagePresentationButton[];
}
| {
type: "select";
placeholder?: string;
options: AgentRuntimeInteractiveReplyOption[];
options: AgentRuntimeMessagePresentationOption[];
};
export type AgentRuntimeMessagePresentation = {
@@ -166,6 +182,9 @@ export type AgentRuntimeReplyPayload = {
sensitiveMedia?: boolean;
presentation?: AgentRuntimeMessagePresentation;
delivery?: AgentRuntimeReplyPayloadDelivery;
/**
* @deprecated Use presentation.
*/
interactive?: AgentRuntimeInteractiveReply;
btw?: {
question: string;

View File

@@ -5,6 +5,9 @@ export {
presentationPageSize,
} from "./presentation-limits.js";
/**
* @deprecated Use MessagePresentation helpers for new rendering paths.
*/
export function reduceInteractiveReply<TState>(
interactive: InteractiveReply | undefined,
initialState: TState,

View File

@@ -96,7 +96,7 @@ function buildTelegramExecApprovalPendingPayloadForTest(params: {
}): ReplyPayload {
return {
text: `Telegram exec approval ${params.request.id}`,
interactive: {
presentation: {
blocks: [
{
type: "buttons",
@@ -512,7 +512,7 @@ describe("exec approval forwarder", () => {
expect(deliver).not.toHaveBeenCalled();
});
it("attaches shared interactive approval buttons in forwarded fallback payloads", async () => {
it("attaches shared presentation approval buttons in forwarded fallback payloads", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({
cfg: makeTargetsCfg([{ channel: "telegram", to: "123" }]),
@@ -535,7 +535,7 @@ describe("exec approval forwarder", () => {
expect(delivery.to).toBe("123");
const payload = requireFirstPayload(deliver);
expect(payload.channelData?.execApproval).toEqual({ approvalId: "req-1" });
expect(payload.interactive).toEqual({
expect(payload.presentation).toEqual({
blocks: [
{
type: "buttons",
@@ -559,6 +559,7 @@ describe("exec approval forwarder", () => {
},
],
});
expect(payload.interactive).toBeUndefined();
});
it("stores exec metadata on generic forwarded fallback payloads", async () => {

View File

@@ -258,7 +258,7 @@ describe("exec approval reply helpers", () => {
sessionKey: undefined,
},
});
expect(payload.interactive).toEqual({
expect(payload.presentation).toEqual({
blocks: [
{
type: "buttons",
@@ -282,6 +282,7 @@ describe("exec approval reply helpers", () => {
},
],
});
expect(payload.interactive).toBeUndefined();
expect(payload.text).toContain("Heads up.");
expect(payload.text).toContain("```txt\n/approve slug-1 allow-once\n```");
expect(payload.text).toContain("```sh\necho ok\n```");
@@ -324,7 +325,7 @@ describe("exec approval reply helpers", () => {
expect(payload.text).toContain(
"The effective approval policy requires approval every time, so Allow Always is unavailable.",
);
expect(payload.interactive).toEqual({
expect(payload.presentation).toEqual({
blocks: [
{
type: "buttons",
@@ -343,6 +344,7 @@ describe("exec approval reply helpers", () => {
},
],
});
expect(payload.interactive).toBeUndefined();
});
it("stores agent and session metadata for downstream suppression checks", () => {

View File

@@ -1,5 +1,10 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { InteractiveReply, InteractiveReplyButton } from "../interactive/payload.js";
import type {
InteractiveReply,
InteractiveReplyButton,
MessagePresentation,
MessagePresentationButton,
} from "../interactive/payload.js";
import { formatHumanList } from "../shared/human-list.js";
import {
normalizeOptionalLowercaseString,
@@ -35,7 +40,7 @@ export type ExecApprovalReplyMetadata = {
export type ExecApprovalActionDescriptor = {
decision: ExecApprovalReplyDecision;
label: string;
style: NonNullable<InteractiveReplyButton["style"]>;
style: NonNullable<MessagePresentationButton["style"]>;
command: string;
};
@@ -162,6 +167,52 @@ function buildApprovalInteractiveButtons(
}));
}
function buildApprovalPresentationButtons(
descriptors: readonly ExecApprovalActionDescriptor[],
): MessagePresentationButton[] {
return descriptors.map((descriptor) => ({
label: descriptor.label,
value: descriptor.command,
style: descriptor.style,
}));
}
export function buildApprovalPresentationFromActionDescriptors(
actions: readonly ExecApprovalActionDescriptor[],
): MessagePresentation | undefined {
const buttons = buildApprovalPresentationButtons(actions);
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
}
export function buildApprovalPresentation(params: {
approvalId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): MessagePresentation | undefined {
return buildApprovalPresentationFromActionDescriptors(
buildExecApprovalActionDescriptors({
approvalCommandId: params.approvalId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
}),
);
}
export function buildExecApprovalPresentation(params: {
approvalCommandId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): MessagePresentation | undefined {
return buildApprovalPresentation({
approvalId: params.approvalCommandId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
});
}
/**
* @deprecated Use buildApprovalPresentationFromActionDescriptors.
*/
export function buildApprovalInteractiveReplyFromActionDescriptors(
actions: readonly ExecApprovalActionDescriptor[],
): InteractiveReply | undefined {
@@ -169,6 +220,9 @@ export function buildApprovalInteractiveReplyFromActionDescriptors(
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
}
/**
* @deprecated Use buildApprovalPresentation.
*/
export function buildApprovalInteractiveReply(params: {
approvalId: string;
ask?: string | null;
@@ -183,6 +237,9 @@ export function buildApprovalInteractiveReply(params: {
);
}
/**
* @deprecated Use buildExecApprovalPresentation.
*/
export function buildExecApprovalInteractiveReply(params: {
approvalCommandId: string;
ask?: string | null;
@@ -335,7 +392,7 @@ export function buildExecApprovalPendingReplyPayload(
return {
text: lines.join("\n\n"),
interactive: buildApprovalInteractiveReply({
presentation: buildApprovalPresentation({
approvalId: params.approvalId,
allowedDecisions,
}),

View File

@@ -69,7 +69,7 @@ async function flushPendingDelivery(): Promise<void> {
}
type DeliveryArgs = {
payloads?: Array<{ text?: string; interactive?: unknown }>;
payloads?: Array<{ text?: string; presentation?: unknown; interactive?: unknown }>;
};
function deliveryArgs(deliver: ReturnType<typeof vi.fn>): DeliveryArgs | undefined {
@@ -137,7 +137,7 @@ describe("plugin approval forwarding", () => {
expect(text).toContain("Sensitive tool call");
expect(text).toContain("plugin-req-1");
expect(text).toContain("/approve");
expect(payload?.interactive).toEqual({
expect(payload?.presentation).toEqual({
blocks: [
{
type: "buttons",
@@ -161,6 +161,7 @@ describe("plugin approval forwarding", () => {
},
],
});
expect(payload?.interactive).toBeUndefined();
});
it("renders only request-scoped plugin approval decisions", async () => {
@@ -179,7 +180,7 @@ describe("plugin approval forwarding", () => {
const payload = firstDeliveredPayload(deliver);
expect(payload?.text).toContain("Reply with: /approve <id> allow-once|deny");
expect(payload?.text).not.toContain("allow-always");
expect(payload?.interactive).toEqual({
expect(payload?.presentation).toEqual({
blocks: [
{
type: "buttons",
@@ -198,6 +199,7 @@ describe("plugin approval forwarding", () => {
},
],
});
expect(payload?.interactive).toBeUndefined();
});
it("includes severity icon for critical", async () => {

View File

@@ -5,7 +5,11 @@ import {
export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
export type InteractiveReplyButton = {
export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral";
export type MessagePresentationButtonStyle = InteractiveButtonStyle;
export type MessagePresentationButton = {
label: string;
value?: string;
url?: string;
@@ -23,44 +27,61 @@ export type InteractiveReplyButton = {
style?: InteractiveButtonStyle;
};
export type InteractiveReplyOption = {
export type MessagePresentationOption = {
label: string;
value: string;
};
/**
* @deprecated Use MessagePresentationButton.
*/
export type InteractiveReplyButton = MessagePresentationButton;
/**
* @deprecated Use MessagePresentationOption.
*/
export type InteractiveReplyOption = MessagePresentationOption;
/**
* @deprecated Use MessagePresentationTextBlock.
*/
export type InteractiveReplyTextBlock = {
type: "text";
text: string;
};
/**
* @deprecated Use MessagePresentationButtonsBlock.
*/
type InteractiveReplyButtonsBlock = {
type: "buttons";
buttons: InteractiveReplyButton[];
};
/**
* @deprecated Use MessagePresentationSelectBlock.
*/
export type InteractiveReplySelectBlock = {
type: "select";
placeholder?: string;
options: InteractiveReplyOption[];
};
/**
* @deprecated Use MessagePresentationBlock.
*/
export type InteractiveReplyBlock =
| InteractiveReplyTextBlock
| InteractiveReplyButtonsBlock
| InteractiveReplySelectBlock;
/**
* @deprecated Use MessagePresentation.
*/
export type InteractiveReply = {
blocks: InteractiveReplyBlock[];
};
export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral";
export type MessagePresentationButtonStyle = InteractiveButtonStyle;
export type MessagePresentationButton = InteractiveReplyButton;
export type MessagePresentationOption = InteractiveReplyOption;
export type MessagePresentationTextBlock = {
type: "text";
text: string;
@@ -215,6 +236,9 @@ function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefi
return undefined;
}
/**
* @deprecated Use normalizeMessagePresentation.
*/
export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined {
const record = toRecord(raw);
if (!record) {
@@ -271,6 +295,9 @@ export function normalizeMessagePresentation(raw: unknown): MessagePresentation
};
}
/**
* @deprecated Use hasMessagePresentationBlocks.
*/
export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply {
return Boolean(normalizeInteractiveReply(value));
}
@@ -279,6 +306,9 @@ export function hasMessagePresentationBlocks(value: unknown): value is MessagePr
return Boolean(normalizeMessagePresentation(value));
}
/**
* @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly.
*/
export function presentationToInteractiveReply(
presentation: MessagePresentation,
): InteractiveReply | undefined {
@@ -339,6 +369,9 @@ export function isMessagePresentationInteractiveBlock(
return block.type === "buttons" || block.type === "select";
}
/**
* @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly.
*/
export function presentationToInteractiveControlsReply(
presentation: MessagePresentation,
): InteractiveReply | undefined {
@@ -347,6 +380,9 @@ export function presentationToInteractiveControlsReply(
});
}
/**
* @deprecated Legacy bridge for old InteractiveReply payloads. New producers should send MessagePresentation.
*/
export function interactiveReplyToPresentation(
interactive: InteractiveReply,
): MessagePresentation | undefined {
@@ -466,6 +502,9 @@ export function hasReplyPayloadContent(
});
}
/**
* @deprecated Use renderMessagePresentationFallbackText with MessagePresentation.
*/
export function resolveInteractiveTextFallback(params: {
text?: string;
interactive?: InteractiveReply;

View File

@@ -9,14 +9,14 @@ import {
describe("plugin-sdk/approval-renderers", () => {
it.each([
{
name: "builds shared approval payloads with generic interactive commands",
name: "builds shared approval payloads with generic presentation commands",
payload: buildApprovalPendingReplyPayload({
approvalId: "plugin:approval-123",
approvalSlug: "plugin:a",
text: "Approval required @everyone",
}),
textExpected: (text: string) => expect(text).toContain("@everyone"),
interactiveExpected: {
presentationExpected: {
blocks: [
{
type: "buttons",
@@ -63,7 +63,7 @@ describe("plugin-sdk/approval-renderers", () => {
},
}),
textExpected: (text: string) => expect(text).toContain("Plugin approval required"),
interactiveExpected: {
presentationExpected: {
blocks: [
{
type: "buttons",
@@ -119,7 +119,7 @@ describe("plugin-sdk/approval-renderers", () => {
}),
textExpected: (text: string) =>
expect(text).toContain("Reply with: /approve <id> allow-once|deny"),
interactiveExpected: {
presentationExpected: {
blocks: [
{
type: "buttons",
@@ -158,7 +158,7 @@ describe("plugin-sdk/approval-renderers", () => {
text: "resolved @everyone",
}),
textExpected: (text: string) => expect(text).toBe("resolved @everyone"),
interactiveExpected: undefined,
presentationExpected: undefined,
channelDataExpected: {
execApproval: {
approvalId: "req-123",
@@ -183,7 +183,7 @@ describe("plugin-sdk/approval-renderers", () => {
},
}),
textExpected: (text: string) => expect(text).toContain("Plugin approval allowed once"),
interactiveExpected: undefined,
presentationExpected: undefined,
channelDataExpected: {
execApproval: {
approvalId: "plugin-approval-123",
@@ -195,13 +195,14 @@ describe("plugin-sdk/approval-renderers", () => {
},
},
},
])("$name", ({ payload, textExpected, interactiveExpected, channelDataExpected }) => {
])("$name", ({ payload, textExpected, presentationExpected, channelDataExpected }) => {
if (payload.text === undefined) {
throw new Error("expected rendered approval text");
}
textExpected(payload.text);
if (interactiveExpected) {
expect(payload.interactive).toEqual(interactiveExpected);
if (presentationExpected) {
expect(payload.presentation).toEqual(presentationExpected);
expect(payload.interactive).toBeUndefined();
}
if (channelDataExpected) {
expect(payload.channelData).toEqual(channelDataExpected);

View File

@@ -1,5 +1,5 @@
import {
buildApprovalInteractiveReply,
buildApprovalPresentation,
type ExecApprovalReplyDecision,
} from "../infra/exec-approval-reply.js";
import {
@@ -27,7 +27,7 @@ export function buildApprovalPendingReplyPayload(params: {
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
return {
text: params.text,
interactive: buildApprovalInteractiveReply({
presentation: buildApprovalPresentation({
approvalId: params.approvalId,
allowedDecisions,
}),

View File

@@ -1,5 +1,8 @@
export {
buildApprovalInteractiveReplyFromActionDescriptors,
buildApprovalPresentation,
buildApprovalPresentationFromActionDescriptors,
buildExecApprovalPresentation,
buildExecApprovalActionDescriptors,
buildExecApprovalPendingReplyPayload,
getExecApprovalApproverDmNoticeText,