Files
openclaw/src/interactive/payload.test.ts
Peter Lee 2d764dd8fe fix(feishu): preserve button command values in fallback text and add Feishu comment guidance with callback privacy (#94385)
* fix(interactive): preserve button command values in fallback text for degraded approval UX

* fix(interactive): keep callback values private in fallback text and narrow Feishu interactive detection

- P1: Skip rendering action.type === "callback" values in
  renderMessagePresentationFallbackText to avoid leaking opaque
  channel/plugin data into user-visible text. Command and legacy
  values are still rendered.
- P2: Replace hasMessagePresentationBlocks/hasInteractiveReplyBlocks
  with isMessagePresentationInteractiveBlock so Feishu comment
  guidance only appears when the presentation actually contains
  buttons or selects, not for text-only blocks.
- Update tests: callback button now shows label-only; all 137 tests pass.

* fix(interactive): only render typed command values in fallback text, keep legacy value private

* fix(feishu): gate document-comment command guidance on actual command action

* docs(message-presentation): document command/callback value fallback visibility

* fix(feishu): omit command guidance when URL overrides fallback command text

* docs: regenerate docs_map.md

* fix(interactive): exclude disabled buttons from fallback command rendering and guidance

* fix(interactive): extract hasRenderedCommandAction, exclude disabled buttons from command fallback

* fix(feishu): preserve command guidance marker through core presentation rendering

* fix(feishu): type-narrow channelData.feishu with isRecord before reading rendered-command marker

* fix(feishu): move hasRenderedCommandAction from public SDK into Feishu plugin as local helper

Keep the helper local to the only caller (Feishu outbound) instead of
adding a new public plugin SDK API contract. The shared fallback renderer
in renderMessagePresentationFallbackText already inlines the same
command-visibility logic; a local helper is sufficient for the Feishu
comment-thread guidance gate.

* refactor(feishu): tighten fallback command marker

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-02 12:42:31 -07:00

328 lines
8.9 KiB
TypeScript

// Interactive payload tests cover validation of interactive response payloads.
import { describe, expect, it } from "vitest";
import {
hasReplyChannelData,
hasReplyContent,
hasReplyPayloadContent,
normalizeInteractiveReply,
normalizeMessagePresentation,
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
} from "./payload.js";
describe("hasReplyChannelData", () => {
it.each([
{ value: undefined, expected: false },
{ value: {}, expected: false },
{ value: [], expected: false },
{ value: { slack: { blocks: [] } }, expected: true },
] as const)("accepts non-empty objects only: %j", ({ value, expected }) => {
expect(hasReplyChannelData(value)).toBe(expected);
});
});
describe("hasReplyContent", () => {
it("treats whitespace-only text and empty structured payloads as empty", () => {
expect(
hasReplyContent({
text: " ",
mediaUrls: ["", " "],
interactive: { blocks: [] },
hasChannelData: false,
}),
).toBe(false);
});
it.each([
{
name: "shared interactive blocks",
input: {
interactive: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }],
},
},
},
{
name: "explicit extra content",
input: {
text: " ",
extraContent: true,
},
},
] as const)("accepts $name", ({ input }) => {
expect(hasReplyContent(input)).toBe(true);
});
});
describe("hasReplyPayloadContent", () => {
it("trims text and falls back to channel data by default", () => {
expect(
hasReplyPayloadContent({
text: " ",
channelData: { slack: { blocks: [] } },
}),
).toBe(true);
});
it.each([
{
name: "explicit channel-data overrides",
payload: {
text: " ",
channelData: {},
},
options: {
hasChannelData: true,
},
},
{
name: "extra content",
payload: {
text: " ",
},
options: {
extraContent: true,
},
},
] as const)("accepts $name", ({ payload, options }) => {
expect(hasReplyPayloadContent(payload, options)).toBe(true);
});
});
describe("interactive payload helpers", () => {
it("normalizes interactive replies and resolves text fallbacks", () => {
const interactive = normalizeInteractiveReply({
blocks: [
{ type: "text", text: "First" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
{ type: "text", text: "Second" },
],
});
expect(interactive).toEqual({
blocks: [
{ type: "text", text: "First" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
{ type: "text", text: "Second" },
],
});
expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond");
});
it("preserves URL-only presentation buttons for native link renderers and fallback text", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
};
expect(presentationToInteractiveReply(presentation)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
});
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
"- Docs: https://example.com/docs",
);
});
it("preserves web app presentation buttons for channel-native renderers", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Launch", web_app: { url: "https://example.com/app" } }],
},
],
};
const normalized = normalizeMessagePresentation(presentation);
expect(normalized).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
},
],
});
expect(presentationToInteractiveReply(normalized!)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
},
],
});
expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe(
"- Launch: https://example.com/app",
);
});
it("normalizes typed presentation actions and bridges them to legacy values", () => {
const normalized = normalizeMessagePresentation({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
},
],
},
],
});
expect(normalized).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
},
],
},
],
});
expect(presentationToInteractiveReply(normalized!)).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
value: "/codex plugins menu",
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
value: "/approve req allow-once",
},
],
},
],
});
});
it("converts only presentation controls for native component renderers", () => {
const presentation = {
title: "Deploy approval",
blocks: [
{ type: "text" as const, text: "Canary is ready." },
{ type: "divider" as const },
{
type: "buttons" as const,
buttons: [
{
label: "Approve",
value: "approve",
style: "success" as const,
reusable: true,
},
],
},
{
type: "select" as const,
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
};
expect(presentationToInteractiveReply(presentation)).toEqual({
blocks: [
{ type: "text", text: "Deploy approval" },
{ type: "text", text: "Canary is ready." },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
},
{
type: "select",
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
});
expect(presentationToInteractiveControlsReply(presentation)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
},
{
type: "select",
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
});
});
it("preserves command values in button fallback text while keeping callback values private", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [
{ label: "Approve", value: "/approve req_1 allow-once" },
{ label: "Deny", action: { type: "command" as const, command: "/approve req_1 deny" } },
{ label: "Ignore", action: { type: "callback" as const, value: "ignore_123" } },
{ label: "Docs", url: "https://example.com/docs" },
{ label: "Disabled", disabled: true },
{
label: "DisabledCmd",
disabled: true,
action: { type: "command" as const, command: "/test" },
},
],
},
],
};
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
[
"- Approve",
"- Deny: `/approve req_1 deny`",
"- Ignore",
"- Docs: https://example.com/docs",
"- Disabled",
"- DisabledCmd",
].join("\n"),
);
});
it("keeps divider-only fallback empty unless a send transport fallback is requested", () => {
const presentation = {
blocks: [{ type: "divider" as const }],
};
expect(renderMessagePresentationFallbackText({ presentation })).toBe("");
expect(
renderMessagePresentationFallbackText({
presentation,
emptyFallback: "---",
}),
).toBe("---");
});
});