From 7d6dfa1939b46cf093389355dbe67254e7bf0756 Mon Sep 17 00:00:00 2001 From: Leon-SK668 <0668001470@xydigit.com> Date: Thu, 13 Aug 2026 13:23:58 +0800 Subject: [PATCH] fix(replies): keep approval-unavailable notices durable (#121179) Punchcard-Session: clear-lantern-summit-v2 --- ...config.hooks-and-send-policy.test-utils.ts | 9 +++++++ .../reply/dispatch-from-config.payloads.ts | 5 ++++ .../dispatch-from-config.prepare-execution.ts | 8 ++++-- ...dispatch-from-config.routing.test-utils.ts | 25 +++++++++++++++++++ .../reply/followup-turn-execution.test.ts | 9 +++++++ src/infra/exec-approval-reply.test.ts | 17 ++++++++----- src/infra/exec-approval-reply.ts | 7 ++++++ 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts index b0e976753b74..5ffc6fea6715 100644 --- a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts @@ -2671,6 +2671,15 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => }, }, }, + { + label: "unavailable exec approvals", + payload: { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + }, + }, { label: "ask-user prompts", payload: { diff --git a/src/auto-reply/reply/dispatch-from-config.payloads.ts b/src/auto-reply/reply/dispatch-from-config.payloads.ts index a39cf0382816..0c0e8eb3e949 100644 --- a/src/auto-reply/reply/dispatch-from-config.payloads.ts +++ b/src/auto-reply/reply/dispatch-from-config.payloads.ts @@ -51,6 +51,10 @@ export function hasExecApprovalPayload(payload: ReplyPayload): boolean { return isRecord(payload.channelData?.execApproval); } +export function hasExecApprovalUnavailablePayload(payload: ReplyPayload): boolean { + return isRecord(payload.channelData?.execApprovalUnavailable); +} + export function hasAskUserPayload(payload: ReplyPayload): boolean { return isRecord(payload.channelData?.askUser); } @@ -59,6 +63,7 @@ export function requiresDurableToolResultDelivery(payload: ReplyPayload): boolea return ( resolveSendableOutboundReplyParts(payload).hasMedia || hasExecApprovalPayload(payload) || + hasExecApprovalUnavailablePayload(payload) || hasAskUserPayload(payload) ); } diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts b/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts index 7c03c47bacae..516ed4fd8130 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts @@ -13,7 +13,11 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { ChooseDispatchRouteReadyState } from "./dispatch-from-config.choose-route.js"; -import { hasAskUserPayload, hasExecApprovalPayload } from "./dispatch-from-config.payloads.js"; +import { + hasAskUserPayload, + hasExecApprovalPayload, + hasExecApprovalUnavailablePayload, +} from "./dispatch-from-config.payloads.js"; import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js"; import { loadGetReplyFromConfigRuntime } from "./dispatch-from-config.runtime-loaders.js"; import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; @@ -121,7 +125,7 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt if (shouldSendToolSummaries()) { return payload; } - if (hasExecApprovalPayload(payload)) { + if (hasExecApprovalPayload(payload) || hasExecApprovalUnavailablePayload(payload)) { return payload; } if (hasAskUserPayload(payload)) { diff --git a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts index 70a76cc9353d..a43be13e9577 100644 --- a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts @@ -427,6 +427,31 @@ describe("dispatchReplyFromConfig", () => { ).toBe(true); }); + it("delivers approval-unavailable notices when verbose tool progress is disabled", async () => { + setNoAbort(); + const payload = { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + } satisfies ReplyPayload; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct" }); + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await requireToolResultHandler(opts?.onToolResult)(payload); + return undefined; + }; + + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledWith(payload); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }); + it("drops ask_user prompts that terminalize before dispatcher delivery", async () => { setNoAbort(); askUserMocks.isAskUserPromptPending.mockResolvedValue(false); diff --git a/src/auto-reply/reply/followup-turn-execution.test.ts b/src/auto-reply/reply/followup-turn-execution.test.ts index d27973f31870..cac797fea71d 100644 --- a/src/auto-reply/reply/followup-turn-execution.test.ts +++ b/src/auto-reply/reply/followup-turn-execution.test.ts @@ -329,6 +329,15 @@ describe("executeFollowupTurn", () => { }, }, }, + { + label: "unavailable exec approvals", + payload: { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + }, + }, { label: "ask-user prompts", payload: { diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index 172e51dae0ff..69bac1be7042 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -688,18 +688,23 @@ describe("exec approval reply helpers", () => { }), ).toEqual({ text: "Careful.\n\nApproval required. I sent approval DMs to the approvers for this account.", + channelData: { + execApprovalUnavailable: { + reason: "no-approval-route", + }, + }, }); }); it.each(unavailableReasonCases)( "builds unavailable payload for reason $reason", ({ reason, channelLabel, expected }) => { - expect( - buildExecApprovalUnavailableReplyPayload({ - reason, - channelLabel, - }).text, - ).toContain(expected); + const payload = buildExecApprovalUnavailableReplyPayload({ + reason, + channelLabel, + }); + expect(payload.text).toContain(expected); + expect(payload.channelData).toEqual({ execApprovalUnavailable: { reason } }); }, ); }); diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 784492fb45d2..482c215d422a 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -475,6 +475,11 @@ export function buildExecApprovalUnavailableReplyPayload( params: ExecApprovalUnavailableReplyParams, ): ReplyPayload { const lines: string[] = []; + const channelData = { + execApprovalUnavailable: { + reason: params.reason, + }, + }; const warningText = params.warningText?.trim(); if (warningText) { lines.push(warningText); @@ -484,6 +489,7 @@ export function buildExecApprovalUnavailableReplyPayload( lines.push(getExecApprovalApproverDmNoticeText()); return { text: lines.join("\n\n"), + channelData, }; } @@ -535,5 +541,6 @@ export function buildExecApprovalUnavailableReplyPayload( return { text: lines.join("\n\n"), + channelData, }; }