fix(imessage): bold approval prompt labels in poll mode (#116221)

* fix(imessage): bold approval prompt labels in poll mode

#113193 added bold headers and labels to the approval reaction prompt, but
iMessage only shows that copy when tapbacks own the controls. On any
poll-capable bridge the details message is built from `manualFallbackPayload`,
the legacy unstyled builder, so every label (`Title:`, `Tool:`, `Host:`,
`CWD:`, `Full id:`, ...) reaches Messages as flat text. Native polls are the
default on a bridge-v2 host, so in practice #85954 still reproduced after it
was closed.

Add `nativeControlsPayload` to `ApprovalReactionPendingContent`: the same rich
copy as `reactionPayload` minus the tapback hint, for channels whose native
controls already own the decision surface. iMessage poll mode now renders it,
so both control paths deliver identical styled copy.

`imsg poll send --question` has no attributed-body channel, so the poll
question keeps the marker-free rendering of that same text; otherwise the
balloon would show literal asterisks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Byq7UrC7ZpMHxoFXcddsa

* refactor(plugin-sdk): carry native-controls prompt copy as text

The iMessage poll path reads exactly one thing from the native-controls
payload: its text. Replace the ReplyPayload field with nativeControlsText so
buildApprovalReactionPendingContent stops running the metadata/session-key
builder for fields no caller reads, and the '?? ""' sentinel goes away.

Relative to main the SDK change stays additive: neither field exists in any
release; nativeControlsPayload only ever existed on this branch.

* refactor(plugin-sdk): expose native-controls prompt text as a builder, not a type field

ClawSweeper flagged that requiring a new member on the shipped
ApprovalReactionPendingContent type is source-incompatible for external
producers that hand-construct it. The hint-free copy does not need to ride
the type at all: export buildApprovalNativeControlsPromptText and let the
iMessage handler call it at payload-build time.

ApprovalReactionPendingContent is now byte-identical to the shipped shape;
the SDK change is a single additive function export. Signal/WhatsApp test
fixtures revert to their original form.

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Omar Shahine
2026-07-30 09:23:47 -07:00
committed by GitHub
parent 668721bcfc
commit 9bebf7ac43
3 changed files with 92 additions and 3 deletions
@@ -361,6 +361,48 @@ describe("imessageApprovalNativeRuntime", () => {
expect(payload.text).toContain("👍 Allow Once");
});
it("carries the same bold headers and labels in tapback and poll mode", async () => {
// #85954: poll mode used to fall back to the unstyled legacy prompt, so
// every label reached Messages as flat text on any poll-capable bridge.
const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
request: {
id: "exec-bold",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "exec-bold",
commandText: "echo hi",
host: "gateway",
cwd: "/tmp/work",
expiresAtMs: 60_000,
actions: [
{ decision: "allow-once", label: "Allow Once", command: "/approve exec-bold allow-once" },
{ decision: "deny", label: "Deny", command: "/approve exec-bold deny" },
],
} as never,
});
for (const text of [payload.text, payload.pollText]) {
expect(text).toContain("**Exec approval required**");
expect(text).toContain("**ID:** exec-bold");
expect(text).toContain("**Host:** gateway");
expect(text).toContain("**CWD:**");
expect(text).toContain("**Expires in:**");
expect(text).toContain("**Full id:**");
}
// The poll owns the controls, so the tapback hint stays out of poll mode.
expect(payload.text).toContain("React with:");
expect(payload.pollText).not.toContain("React with:");
});
describe("native poll controls", () => {
const pollDeliverArgs = {
cfg: {
@@ -491,6 +533,27 @@ describe("imessageApprovalNativeRuntime", () => {
]);
});
it("keeps markdown markers out of the poll question", async () => {
// The details message is styled through attributedBody ranges, but
// `imsg poll send --question` has no formatting channel, so the balloon
// would otherwise show literal asterisks.
const pollText = ["**Exec approval required**", "**ID:** exec-poll"].join("\n");
await imessageApprovalNativeRuntime.transport.deliverPending({
...pollDeliverArgs,
pendingPayload: { ...pollDeliverArgs.pendingPayload, pollText },
});
// The send path converts the markers into typed ranges itself.
expect(sendMock.sendMessageIMessage).toHaveBeenCalledWith(
"+15551230000",
pollText,
expect.objectContaining({ conversationReadOrigin: "direct-operator" }),
);
const question = actionsMock.sendPoll.mock.calls[0]?.[0]?.question;
expect(question).toBe("Exec approval required\nID: exec-poll");
expect(question).not.toContain("**");
});
it("binds the poll before deliverPending returns", async () => {
await imessageApprovalNativeRuntime.transport.deliverPending(pollDeliverArgs);
@@ -8,7 +8,10 @@ import {
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime";
import {
buildApprovalNativeControlsPromptText,
buildApprovalReactionPendingContent,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
ExecApprovalRequest,
@@ -32,6 +35,7 @@ import {
unregisterIMessageApprovalReactionTarget,
type IMessageApprovalConversationKey,
} from "./approval-reactions.js";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
import { normalizeIMessageMessagingTarget } from "./normalize.js";
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
import { sendMessageIMessage } from "./send.js";
@@ -97,7 +101,9 @@ function buildPendingPayload(params: {
text: pendingContent.reactionPayload.text ?? "",
// The native poll owns the primary controls. Manual commands stay in the
// details message because bridge capability cannot prove recipient support.
pollText: pendingContent.manualFallbackPayload.text ?? "",
// Same bold headers and labels as the tapback prompt (#85954): both are
// delivered through the attributed-body send path.
pollText: buildApprovalNativeControlsPromptText({ view: params.view, nowMs: params.nowMs }),
allowedDecisions: pendingContent.reactionPayload.allowedDecisions,
};
}
@@ -242,7 +248,10 @@ async function deliverIMessageApprovalPoll(params: {
const runtime = await loadIMessageActionsRuntime();
const sent = await runtime.sendPoll({
chatGuid,
question: params.question,
// `imsg poll send --question` has no attributed-body channel, so the
// question keeps the marker-free rendering of the same prompt copy the
// details message delivers with typed formatting ranges.
question: extractMarkdownFormatRuns(params.question).text,
choices: options.map((option) => option.text),
suppressComment: true,
options: { ...cliOptions, chatGuid },
@@ -499,6 +499,23 @@ export function buildApprovalReactionPendingContent(params: {
return { reactionPayload, manualFallbackPayload };
}
/**
* Prompt copy for channels whose native controls (Apple Messages polls, inline
* buttons) own the decision surface. Same bold headers and labels as the
* reaction prompt (#85954) minus the tapback hint, which would advertise a
* second, redundant control path next to the native one.
*/
export function buildApprovalNativeControlsPromptText(params: {
view: PendingApprovalView;
nowMs: number;
}): string {
return buildApprovalReactionPromptText({
view: params.view,
nowMs: params.nowMs,
reactionHint: null,
});
}
/** Build reaction and manual-fallback pending approval content directly from a request. */
export function buildApprovalReactionPendingContentForRequest(params: {
request: ApprovalRequest;