Files
openclaw/extensions/signal/src/approval-handler.runtime.test.ts
Omar Shahine e430a1beb2 feat(approvals): emit bold headers and labels in approval prompts (#113193)
* feat(approvals): emit bold headers and labels in approval prompts

Approval prompts carried plain-text labels, so iMessage showed no formatting
even though its send path now translates markdown into attributed-body ranges
(the markdown-core profile refactor, #113002). Emit bold on the headers and
field labels so channels that render markdown show formatted approval text:
iMessage into native ranges, other markdown channels into their native bold,
and channels that downgrade drop the markers cleanly.

Closes #85954.

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

* test(approvals): update prompt-text assertions for bold labels

* feat(approvals): bold the auto-review rationale in reaction prompts

The rationale is the reason for the interruption, so it should stand out.
Generated text, but the reaction-runtime renderers parse to an IR that
tolerates stray markers, so a rationale containing a lone marker degrades
gracefully rather than breaking the emphasis span.

* fix(approvals): preserve reaction binding and Signal rendering for bold prompts

Codex + local ClawSweeper caught that bolding the prompt headers/labels broke
downstream consumers of the visible approval text:

- Reaction/tapback binding on iMessage, Signal, and WhatsApp anchors on the
  plain `Exec approval required` / `ID:` format. Strip `**` markers in each
  channel's binding parser before matching, so binding still correlates the
  delivered prompt. Adds an iMessage bold-format binding regression test.
- Signal sent the approval payload with textMode "plain", so the markers would
  reach users literally. Switch Signal's approval sends to markdown mode;
  markdownToSignalText renders the headers as native bold. WhatsApp already
  renders markdown by default; iMessage renders via extractMarkdownFormatRuns.

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

* style(approvals): oxfmt the touched approval files

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:06:05 -07:00

221 lines
7.0 KiB
TypeScript

// Signal tests cover approval handler plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMocks = vi.hoisted(() => ({
sendTypingSignal: vi.fn(),
sendMessageSignal: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendTypingSignal: sendMocks.sendTypingSignal,
sendMessageSignal: sendMocks.sendMessageSignal,
}));
const { signalApprovalNativeRuntime } = await import("./approval-handler.runtime.js");
function buildPendingContent(params: {
manualText: string;
reactionText?: string;
allowedDecisions?: readonly ("allow-once" | "allow-always" | "deny")[];
}) {
const allowedDecisions = params.allowedDecisions ?? ["allow-once"];
return {
manualFallbackPayload: { text: params.manualText },
reactionPayload: {
text: params.reactionText ?? params.manualText,
allowedDecisions,
reactionBindings: [],
},
};
}
describe("Signal approval native runtime", () => {
beforeEach(() => {
sendMocks.sendTypingSignal.mockReset().mockResolvedValue(true);
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
messageId: "1700000000000",
timestamp: 1700000000000,
receipt: { parts: [] },
});
});
it("uses the live Signal RPC context when delivering approval prompts", async () => {
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
plannedTarget: { target: { to: "+15551230000" } },
accountId: "default",
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
} as never);
expect(prepared?.target).toMatchObject({
to: "+15551230000",
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
});
await signalApprovalNativeRuntime.transport.deliverPending({
cfg: {},
preparedTarget: prepared!.target,
pendingPayload: buildPendingContent({ manualText: "approval" }),
} as never);
expect(sendMocks.sendTypingSignal).toHaveBeenCalledWith("+15551230000", {
cfg: {},
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
});
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
cfg: {},
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
textMode: "markdown",
});
});
it("resolves aliases before delivering native approval prompts", async () => {
const cfg = {
channels: {
signal: {
allowFrom: ["+15551230000"],
aliases: {
me: "+15551230000",
},
},
},
};
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
cfg,
plannedTarget: { target: { to: "signal:me" } },
accountId: "default",
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
} as never);
expect(prepared?.target).toMatchObject({
to: "+15551230000",
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
});
const entry = await signalApprovalNativeRuntime.transport.deliverPending({
cfg,
preparedTarget: prepared!.target,
pendingPayload: buildPendingContent({ manualText: "approval" }),
} as never);
expect(entry).toMatchObject({
to: "+15551230000",
conversationKey: "+15551230000",
});
expect(sendMocks.sendTypingSignal).toHaveBeenCalledWith("+15551230000", {
cfg,
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
});
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
cfg,
accountId: "default",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
textMode: "markdown",
});
});
it("resolves default-account aliases before delivering native approval prompts", async () => {
const cfg = {
channels: {
signal: {
defaultAccount: "work",
accounts: {
work: {
aliases: {
ops: "+15551230000",
},
},
},
},
},
};
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
cfg,
plannedTarget: { target: { to: "signal:ops" } },
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
} as never);
expect(prepared?.target).toMatchObject({
to: "+15551230000",
accountId: "work",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
});
await signalApprovalNativeRuntime.transport.deliverPending({
cfg,
preparedTarget: prepared!.target,
pendingPayload: buildPendingContent({ manualText: "approval" }),
} as never);
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
cfg,
accountId: "work",
baseUrl: "http://127.0.0.1:18080",
account: "+15550001111",
textMode: "markdown",
});
});
it("only renders reaction hints when the Signal target author can be bound", async () => {
const cfg = { channels: { signal: { allowFrom: ["+15551230000"] } } };
const unbound = await signalApprovalNativeRuntime.transport.prepareTarget({
plannedTarget: { target: { to: "+15551230000" } },
accountId: "default",
context: { baseUrl: "http://127.0.0.1:18080" },
} as never);
await signalApprovalNativeRuntime.transport.deliverPending({
cfg,
preparedTarget: unbound!.target,
pendingPayload: buildPendingContent({
manualText:
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
reactionText:
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
allowedDecisions: ["allow-once", "deny"],
}),
} as never);
expect(sendMocks.sendMessageSignal).toHaveBeenLastCalledWith(
"+15551230000",
expect.not.stringContaining("React with:"),
expect.any(Object),
);
const bound = await signalApprovalNativeRuntime.transport.prepareTarget({
plannedTarget: { target: { to: "+15551230000" } },
accountId: "default",
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
} as never);
await signalApprovalNativeRuntime.transport.deliverPending({
cfg,
preparedTarget: bound!.target,
pendingPayload: buildPendingContent({
manualText:
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
reactionText:
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
allowedDecisions: ["allow-once", "deny"],
}),
} as never);
expect(sendMocks.sendMessageSignal).toHaveBeenLastCalledWith(
"+15551230000",
expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
expect.any(Object),
);
});
});