mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user