fix(channels): preserve failed agent run reactions (#122009)

This commit is contained in:
Peter Steinberger
2026-08-11 15:26:44 -07:00
committed by GitHub
parent 285e58e5ef
commit b350f76484
26 changed files with 369 additions and 62 deletions
@@ -1 +1 @@
{"contentHash":"39d7eea281be7a268c1878155930034027f73ae6404622ab09041300bda41991","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"71e2548711edb5372870a7afcef25547aae3d2484578c88610749e90ebe5f244","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
+8
View File
@@ -565,6 +565,14 @@ surfaces:
- `openclaw/plugin-sdk/inbound-envelope` and
`openclaw/plugin-sdk/channel-inbound` for inbound route/envelope and
record-and-dispatch wiring
- `readAgentRunTerminalOutcome(dispatchResult)` from
`openclaw/plugin-sdk/channel-inbound` when terminal reactions or status UI
must distinguish a completed core agent run from a recovered failed run. It
returns `"completed"` or `"failed"` only when a core run actually started,
and `undefined` for commands, dedupe, busy, pre-run abort, and custom dispatch
results. Delivery counts and visibility remain transport facts, including
successful delivery of an error payload; the process-local carrier is not
serialized to JSON.
- `createInboundEventDeliveryCorrelation(...)` from
`openclaw/plugin-sdk/inbound-event-delivery` when successful outbound sends must
retire an active inbound-event marker; create one tracker per channel and
@@ -11,6 +11,7 @@ import {
deliverDiscordReply,
discordTargetMocksForTest as discordTargetMocks,
dispatchInboundMessageForTest as dispatchInboundMessage,
readAgentRunTerminalOutcomeForTest as readAgentRunTerminalOutcome,
getLastDispatchReplyOptions,
runProcessDiscordMessage,
sendMocksForTest as sendMocks,
@@ -277,6 +278,27 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).not.toContain(DEFAULT_EMOJIS.done);
});
it("marks a recovered agent failure as failed after delivering its visible error reply", async () => {
readAgentRunTerminalOutcome.mockReturnValueOnce("failed");
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "Something failed", isError: true });
await params?.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: { final: 1, tool: 0, block: 0 },
};
});
const ctx = await createAutomaticSourceDeliveryContext();
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
const emojis = getReactionEmojis();
expect(emojis).toContain(DEFAULT_EMOJIS.error);
expect(emojis).not.toContain(DEFAULT_EMOJIS.done);
});
it("can bind status reactions to an explicitly tracked reaction target", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
@@ -235,6 +235,7 @@ const dispatchInboundMessage = vi.hoisted(() =>
counts: { final: 0, tool: 0, block: 0 },
})),
);
const readAgentRunTerminalOutcome = vi.hoisted(() => vi.fn());
const recordInboundSession = vi.hoisted(() =>
vi.fn<(params?: unknown) => Promise<void>>(async () => {}),
);
@@ -270,6 +271,7 @@ export const sendMocksForTest = sendMocks;
export const typingMocksForTest = typingMocks;
export const discordTargetMocksForTest = discordTargetMocks;
export const dispatchInboundMessageForTest = dispatchInboundMessage;
export const readAgentRunTerminalOutcomeForTest = readAgentRunTerminalOutcome;
export const recordInboundSessionForTest = recordInboundSession;
export const createDiscordRestClientSpyForTest = createDiscordRestClientSpy;
let createBaseDiscordMessageContext: typeof import("./message-handler.test-harness.js").createBaseDiscordMessageContext;
@@ -403,6 +405,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const replyRuntime = await import("openclaw/plugin-sdk/reply-runtime");
return {
...actual,
readAgentRunTerminalOutcome,
dispatchChannelInboundTurn: async (
plan: import("openclaw/plugin-sdk/channel-inbound").ChannelInboundTurnPlan<"provider_message_sending">,
) => {
@@ -585,6 +588,7 @@ export function registerDiscordProcessTestLifecycle() {
deliverDiscordReply.mockClear();
createDiscordDraftStream.mockClear();
dispatchInboundMessage.mockClear();
readAgentRunTerminalOutcome.mockReset().mockReturnValue(undefined);
recordInboundSession.mockClear();
readSessionUpdatedAt.mockClear();
getSessionEntry.mockClear();
@@ -4,6 +4,7 @@ import { resolveAgentConfig, resolveHumanDelayConfig } from "openclaw/plugin-sdk
import {
dispatchChannelInboundTurn,
hasFinalInboundReplyDispatch,
readAgentRunTerminalOutcome,
} from "openclaw/plugin-sdk/channel-inbound";
import {
bindIngressLifecycleToReplyOptions,
@@ -91,18 +92,14 @@ async function processDiscordMessageInner(
accountId,
token,
runtime,
guildHistories,
historyLimit,
textLimit,
replyToMode,
message,
messageChannelId,
canonicalMessageId,
isGuildMessage,
isDirectMessage,
isGroupDm,
messageText,
channelConfig,
threadBindings,
route,
abortSignal,
@@ -172,7 +169,7 @@ async function processDiscordMessageInner(
sessionKey: ctxPayload.SessionKey,
accountId,
sourceChannelId: messageChannelId,
sourceMessageId: canonicalMessageId ?? message.id,
sourceMessageId: ctx.canonicalMessageId ?? message.id,
sourceReplyReference,
log: logVerbose,
});
@@ -644,13 +641,13 @@ async function processDiscordMessageInner(
: {
isGroup: isGuildMessage,
historyKey: messageChannelId,
historyMap: guildHistories,
limit: historyLimit,
historyMap: ctx.guildHistories,
limit: ctx.historyLimit,
},
replyOptions: {
...(turnAdoptionLifecycle ? bindIngressLifecycleToReplyOptions(turnAdoptionLifecycle) : {}),
abortSignal,
skillFilter: channelConfig?.skills,
skillFilter: ctx.channelConfig?.skills,
sourceReplyDeliveryMode,
typingKeepalive: shouldDisableCoreTypingKeepalive ? false : undefined,
// The primary turn already owns one correlation; each queued followup
@@ -717,6 +714,7 @@ async function processDiscordMessageInner(
activeThreadRoute.end();
endDeliveryCorrelation();
await draftPreview.cleanup();
dispatchError ||= readAgentRunTerminalOutcome(dispatchResult) === "failed";
const finalDeliveryFailed = (dispatchResult?.failedCounts?.final ?? 0) > 0;
await reactions.finish({ dispatchAborted, dispatchError, finalDeliveryFailed });
}
@@ -20,7 +20,7 @@ type DispatchInboundMessageMockParams = {
ctx: MsgContext;
cfg?: OpenClawConfig;
dispatcher?: {
sendFinalReply: (payload: { text: string }) => void;
sendFinalReply: (payload: { text: string; isError?: boolean }) => void;
markComplete: () => void;
waitForIdle: () => Promise<void>;
};
@@ -45,6 +45,7 @@ const {
recordInboundSessionMock,
logVerboseMock,
shouldLogVerboseMock,
readAgentRunTerminalOutcomeMock,
capture,
} = vi.hoisted(() => {
const captureState: { ctx?: MsgContext } = {};
@@ -61,6 +62,7 @@ const {
}),
logVerboseMock: vi.fn(),
shouldLogVerboseMock: vi.fn(() => false),
readAgentRunTerminalOutcomeMock: vi.fn(),
capture: captureState,
};
});
@@ -98,6 +100,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
return {
...actual,
readAgentRunTerminalOutcome: readAgentRunTerminalOutcomeMock,
runChannelInboundEvent: async (params: RunParams) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
@@ -394,6 +397,7 @@ describe("signal createSignalEventHandler inbound context", () => {
enqueueSystemEventMock.mockReset();
recordInboundSessionMock.mockReset().mockResolvedValue(undefined);
dispatchInboundMessageMock.mockClear();
readAgentRunTerminalOutcomeMock.mockReset().mockReturnValue(undefined);
logVerboseMock.mockClear();
shouldLogVerboseMock.mockReset().mockReturnValue(false);
approvalReactionMocks.maybeResolveSignalApprovalReaction.mockReset().mockResolvedValue(false);
@@ -992,6 +996,41 @@ describe("signal createSignalEventHandler inbound context", () => {
expect(sentEmojis).not.toContain("✅");
});
it("marks a delivered recovered agent failure as a Signal error outcome", async () => {
const deliverReplies = vi.fn(async () => undefined);
readAgentRunTerminalOutcomeMock.mockReturnValueOnce("failed");
dispatchInboundMessageMock.mockImplementationOnce(
async (params: DispatchInboundMessageMockParams) => {
capture.ctx = params.ctx;
params.dispatcher?.sendFinalReply({ text: "agent run failed", isError: true });
await params.dispatcher?.waitForIdle();
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 1 },
};
},
);
const handler = createTestHandler({
cfg: createStatusReactionConfig(),
deliverReplies,
});
await receiveDirectMessage(handler);
for (let i = 0; i < 5; i += 1) {
await nextTimerTick();
}
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "agent run failed", isError: true })],
}),
);
const sentEmojis = sentReactionEmojis();
expect(sentEmojis).toContain("❌");
expect(sentEmojis).not.toContain("✅");
expect(sentEmojis.at(-1)).toBe("👀");
});
it("targets Signal group status reactions with groupId and message author", async () => {
const handler = createTestHandler({
cfg: createGroupAllowlistConfig({
@@ -21,6 +21,7 @@ import {
formatInboundFromLabel,
logInboundDrop,
matchesMentionPatterns,
readAgentRunTerminalOutcome,
resolveInboundMentionDecision,
resolveEnvelopeFormatOptions,
hasVisibleInboundReplyDispatch,
@@ -573,9 +574,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
result.dispatched && hasVisibleInboundReplyDispatch(result.dispatchResult);
const hasDeliveryFailure =
result.dispatched && hasSignalStatusReplyDeliveryFailure(result.dispatchResult);
const hasAgentRunFailure =
result.dispatched && readAgentRunTerminalOutcome(result.dispatchResult) === "failed";
void finalizeSignalStatusReaction({
controller: statusReactionController,
outcome: hasFinalResponse && !hasDeliveryFailure ? "done" : "error",
outcome:
hasFinalResponse && !hasDeliveryFailure && !hasAgentRunFailure ? "done" : "error",
}).catch((err: unknown) => {
logVerbose(`signal: status reaction finalize failed: ${String(err)}`);
});
@@ -92,6 +92,7 @@ type TestDispatchSequenceEntry =
let mockedDispatchSequence: TestDispatchSequenceEntry[] = [];
let mockedQueuedDispatchCounts: TestDispatchCounts = { tool: 0, block: 0, final: 0 };
let mockedDispatcherCapturesDeliveryErrors = false;
let mockedAgentRunTerminalOutcome: "completed" | "failed" | undefined;
let mockedProgressEvents: string[] = [];
let mockedEmptyProgressToolName: string | undefined;
@@ -980,6 +981,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
return {
...actual,
readAgentRunTerminalOutcome: () => mockedAgentRunTerminalOutcome,
dispatchChannelInboundTurn: async (params: DispatchParams) => {
capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
if (mockedReplyOptionEvents.length > 0) {
@@ -1149,6 +1151,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedQueuedDispatchCounts = { tool: 0, block: 0, final: 0 };
mockedDispatcherCapturesDeliveryErrors = false;
mockedAgentRunTerminalOutcome = undefined;
mockedProgressEvents = [];
mockedEmptyProgressToolName = undefined;
mockedReplyOptionEvents = [];
@@ -1955,6 +1958,34 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(statusReactionControllerMock.setDone).toHaveBeenCalledTimes(1);
});
it("marks a recovered agent failure as failed after delivering its visible error reply", async () => {
mockedAgentRunTerminalOutcome = "failed";
mockedNativeStreaming = true;
mockedSlackStreamingMode = "progress";
mockedReplyOptionEvents = [{ kind: "item", progressText: "Recovering failed run" }];
mockedDispatchSequence = [
{ kind: "final", payload: { text: "Something failed", isError: true } },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
cfg: { messages: { statusReactions: { enabled: true } } },
accountConfig: {
streaming: { mode: "progress", progress: { nativeTaskCards: true, render: "rich" } },
},
ackReactionMessageTs: "171234.111",
ackReactionPromise: Promise.resolve(true),
}),
);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expect(startSlackStreamMock).toHaveBeenCalledTimes(1);
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
expect(collectNativeTaskUpdates().at(-1)).toEqual(expect.objectContaining({ status: "error" }));
expect(statusReactionControllerMock.setError).toHaveBeenCalledTimes(1);
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
});
it("keeps Slack lifecycle reactions off by default when an ack reaction exists", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
@@ -2,6 +2,7 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
dispatchChannelInboundTurn,
readAgentRunTerminalOutcome,
type InboundReplyRecordOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
@@ -354,6 +355,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
};
let dispatchError: unknown;
let agentRunFailed = false;
let queuedFinal = false;
let counts: Partial<Record<ReplyDispatchKind, number>> = {};
try {
@@ -490,6 +492,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const result = turnResult.dispatchResult;
queuedFinal = result.queuedFinal;
counts = result.counts;
agentRunFailed = readAgentRunTerminalOutcome(result) === "failed";
}
} catch (err) {
dispatchError = err;
@@ -508,7 +511,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const completionChunks =
progress.useNativeProgressStreaming && !progress.nativeProgressCompletionSent
? progress.buildNativeProgressCompletionChunks(
dispatchError ? "error" : progress.nativeProgressTerminalStatus,
dispatchError || agentRunFailed ? "error" : progress.nativeProgressTerminalStatus,
)
: undefined;
if (completionChunks?.length) {
@@ -567,7 +570,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
);
if (statusReactionsEnabled) {
if (dispatchError) {
if (dispatchError || agentRunFailed) {
await statusReactions.setError();
} else if (anyReplyDelivered) {
await statusReactions.setDone();
@@ -1,5 +1,6 @@
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import {
readAgentRunTerminalOutcome,
runChannelInboundEvent,
type ChannelInboundTurnPlan,
} from "openclaw/plugin-sdk/channel-inbound";
@@ -313,6 +314,7 @@ export async function runTelegramDispatchTurn(turn: Turn) {
return false;
}
turn.queuedFinal ||= turnResult.dispatchResult.queuedFinal;
turn.agentRunFailed = readAgentRunTerminalOutcome(turnResult.dispatchResult) === "failed";
turn.noVisibleReplyFallbackEligible =
turnResult.dispatchResult.noVisibleReplyFallbackEligible === true;
if ((turnResult.dispatchResult.counts?.final ?? 0) > 0) {
@@ -5,13 +5,16 @@ import {
createContext,
createDirectSessionPayload,
createReasoningStreamContext,
createStatusReactionController,
createTelegramDraftStream,
deliverReplies,
dispatchReplyWithBufferedBlockDispatcher,
dispatchWithContext,
editMessageTelegram,
emitTelegramMessageSentHooks,
expectDeliveredReply,
expectDeliverRepliesParams,
expectRecordFields,
expectWindowCollapsedTo,
mockCallArg,
requireInvocationOrder,
@@ -120,6 +123,7 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
])(
"finalizes the default streamed draft in place after an unexpected reply failure in a $label",
async ({ createMessageContext }) => {
const statusReactionController = createStatusReactionController();
const answerDraftStream = createTestDraftStream({
onWaitForInFlight: () => answerDraftStream.setMessageId(2001),
});
@@ -133,14 +137,17 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
return await dispatchReplyWithBufferedBlockDispatcherRuntime({
...params,
replyResolver: async (_ctx, opts) => {
opts?.onAgentRunStart?.("failed-run");
partialAccepted = await opts?.onPartialReply?.({ text: "partial answer" });
throw new Error("unexpected model failure");
},
});
});
const messageContext = createMessageContext();
messageContext.statusReactionController = statusReactionController as never;
await dispatchWithContext({
context: createMessageContext(),
context: messageContext,
streamMode: "partial",
telegramCfg: { streaming: { mode: "partial" } },
});
@@ -157,6 +164,39 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
expect(emitTelegramMessageSentHooks).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(emitTelegramMessageSentHooks), { success: true });
await vi.waitFor(() => {
expect(statusReactionController.restoreInitial).toHaveBeenCalledTimes(1);
});
expect(statusReactionController.setError).toHaveBeenCalledTimes(1);
expect(statusReactionController.setDone).not.toHaveBeenCalled();
expect(
requireInvocationOrder(
statusReactionController.setThinking,
0,
"initial thinking status reaction",
),
).toBeLessThan(
requireInvocationOrder(
statusReactionController.setError,
0,
"terminal error status reaction",
),
);
expect(
requireInvocationOrder(
statusReactionController.setError,
0,
"terminal error status reaction",
),
).toBeLessThan(
requireInvocationOrder(
statusReactionController.restoreInitial,
0,
"initial status reaction restoration",
),
);
},
);
@@ -517,7 +517,8 @@ export const dispatchTelegramMessage = async (
status.finalizeInBackground(
{
outcome:
!turn.finalAnswerDelivered && (turn.dispatchError != null || sentFallback)
turn.agentRunFailed ||
(!turn.finalAnswerDelivered && (turn.dispatchError != null || sentFallback))
? "error"
: "done",
},
@@ -246,6 +246,7 @@ export type TelegramDispatchTurn = TelegramDispatchTurnConfig &
TelegramDeliveryStateSlice &
TelegramReplyStateSlice & {
queuedFinal: boolean;
agentRunFailed?: boolean;
noVisibleReplyFallbackEligible: boolean;
suppressSilentReplyFallback: boolean;
hadErrorReplyFailureOrSkip: boolean;
@@ -35,6 +35,7 @@ type CapturedDispatchParams = {
const {
dispatchReplyWithBufferedBlockDispatcherMock,
deliverInboundReplyWithMessageSendContextMock,
readAgentRunTerminalOutcomeMock,
sourceReplyDeliveryModeContexts,
} = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(async (params: CapturedDispatchParams) => {
@@ -44,9 +45,18 @@ const {
deliverInboundReplyWithMessageSendContextMock: vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => null,
),
readAgentRunTerminalOutcomeMock: vi.fn(),
sourceReplyDeliveryModeContexts: [] as unknown[],
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
readAgentRunTerminalOutcome: readAgentRunTerminalOutcomeMock,
};
});
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
return {
@@ -701,6 +711,7 @@ describe("whatsapp inbound dispatch", () => {
capturedDispatchParams = undefined;
sourceReplyDeliveryModeContexts.length = 0;
dispatchReplyWithBufferedBlockDispatcherMock.mockClear();
readAgentRunTerminalOutcomeMock.mockReset().mockReturnValue(undefined);
deliverInboundReplyWithMessageSendContextMock.mockReset();
deliverInboundReplyWithMessageSendContextMock.mockResolvedValue({
status: "unsupported",
@@ -1888,6 +1899,52 @@ describe("whatsapp inbound dispatch", () => {
expect(rememberSentText).not.toHaveBeenCalled();
});
it("keeps visible delivery successful while marking a failed agent run as an error", async () => {
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
const rememberSentText = vi.fn();
const statusReactionController = {
setQueued: vi.fn(),
setThinking: vi.fn(),
setTool: vi.fn(),
setCompacting: vi.fn(),
cancelPending: vi.fn(),
setDone: vi.fn(async () => undefined),
setError: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
restoreInitial: vi.fn(async () => undefined),
};
readAgentRunTerminalOutcomeMock.mockReturnValueOnce("failed");
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: CapturedDispatchParams) => {
capturedDispatchParams = params;
await params.dispatcherOptions?.deliver?.({ text: "visible failure" }, { kind: "final" });
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 1 },
};
},
);
await expect(
dispatchBufferedReply({
deliverReply,
rememberSentText,
statusReactionController,
}),
).resolves.toBe(true);
await vi.waitFor(() => {
expect(statusReactionController.restoreInitial).toHaveBeenCalledTimes(1);
});
expect(deliverReply).toHaveBeenCalledTimes(1);
expect(rememberSentText).toHaveBeenCalledTimes(1);
expect(statusReactionController.setError).toHaveBeenCalledTimes(1);
expect(statusReactionController.setDone).not.toHaveBeenCalled();
expect(statusReactionController.setError.mock.invocationCallOrder[0]).toBeLessThan(
statusReactionController.restoreInitial.mock.invocationCallOrder[0] ?? 0,
);
});
it("does not treat generated WhatsApp text as sent when the provider did not accept it", async () => {
const deliverReply = vi.fn(async () => unacceptedDeliveryResult());
const rememberSentText = vi.fn();
@@ -3,6 +3,7 @@ import type { StatusReactionController } from "openclaw/plugin-sdk/channel-feedb
import {
createChannelPartialDeliveryError,
isChannelPartialDeliveryError,
readAgentRunTerminalOutcome,
type ChannelInboundTurnPlan,
toInboundMediaFactsWithMetadata,
} from "openclaw/plugin-sdk/channel-inbound";
@@ -922,7 +923,10 @@ export function createWhatsAppReplyPlan(params: {
if (statusReactionController) {
void finalizeWhatsAppStatusReaction({
controller: statusReactionController,
outcome: didDeliverVisibleReply ? "done" : "error",
outcome:
readAgentRunTerminalOutcome(dispatchResult) === "failed" || !didDeliverVisibleReply
? "error"
: "done",
});
}
if (params.shouldClearGroupHistory) {
+4 -2
View File
@@ -272,7 +272,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +2: add high-use coercion primitives while retaining shipped object-record exports.
// +2: channel-neutral location and provider-update hook contracts.
// +1: QQBot 2.0.1 operator-approval Gateway client compatibility export.
4871,
// +2: narrow channel agent-run terminal reader and outcome contract.
4873,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -336,7 +337,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +3: add canonical coercion exports while retaining the shipped asString compatibility name.
// +2: add high-use callable coercion primitives while retaining shipped object-record exports.
// +1: QQBot 2.0.1 operator-approval Gateway client compatibility export.
2925,
// +1: narrow channel agent-run terminal reader.
2926,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -1,5 +1,6 @@
// Imported by dispatch-from-config.test.ts to keep its mocked suite in one Vitest module graph.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { readAgentRunTerminalOutcome } from "../../channels/turn/agent-run-terminal-outcome.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createApprovalNativeRouteReporter } from "../../infra/approval-native-route-coordinator.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
@@ -407,9 +408,10 @@ describe("dispatchReplyFromConfig", () => {
});
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
expect(readAgentRunTerminalOutcome(result)).toBeUndefined();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
text: "⚙️ Agent was aborted.",
});
@@ -622,7 +622,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
) {
throw error;
}
failDispatchReplyOperation(error);
failDispatchReplyOperation(error, "failed");
return buildTerminalAgentRunFailureReplyPayload({
visibleReplyDelivered: true,
sessionCtx: ctx,
@@ -1,4 +1,5 @@
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import { recordAgentRunTerminalOutcome } from "../../channels/turn/agent-run-terminal-outcome.js";
import { logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { cleanDeferredFinalText } from "../../tts/captioned-final.js";
@@ -344,6 +345,7 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
}
}
counts.final += routedFinalCount;
const agentRunTerminalOutcome = state.getAgentRunTerminalOutcome();
state.commitInboundDedupeIfClaimed();
const dispatchOutcome = queueCapRejected ? "skipped" : "completed";
const dispatchReason = queueCapRejected
@@ -358,35 +360,39 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
state.recordProcessed(dispatchOutcome, dispatchReason ? { reason: dispatchReason } : undefined);
state.markIdle(queueCapRejected ? "message_queue_cap_rejected" : "message_completed");
state.completeDispatchReplyOperation();
const result = state.attachSourceReplyDeliveryMode({
queuedFinal,
counts,
...(state.routeState.sessionMetadataChangesForResult
? { sessionMetadataChanges: state.routeState.sessionMetadataChangesForResult }
: {}),
...(getObservedReplyDelivery() ? { observedReplyDelivery: true } : {}),
// Eligibility keys off settled visible delivery: a suppressed or cancelled
// final (including the core fallback itself) leaves channel-level recovery
// eligible, while any settled visible delivery clears it. An aborted or
// timed-out settle leaves delivery unresolved, and a fallback reported as
// delivered must not stay recoverable — either could double-send.
...(noVisibleReplyFallbackDirected &&
queuedSettleResult === "settled" &&
!turnLedger.hasVisibleDelivery() &&
!noVisibleReplyFallbackDelivered &&
!getObservedReplyDelivery() &&
!replyAcceptedByActiveRun &&
!emptyFinalAllowedAsSilent &&
!deliberateSilentTerminalReply &&
!pendingContinuation &&
!channelTransformSuppressed
? { noVisibleReplyFallbackEligible: true }
: {}),
...(noVisibleReplyFallbackDelivered ? { noVisibleReplyFallbackDelivered: true } : {}),
...(deliberateSilentTerminalReply ? { deliberateSilentTerminalReply: true } : {}),
...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}),
});
if (agentRunTerminalOutcome) {
recordAgentRunTerminalOutcome(result, agentRunTerminalOutcome);
}
return {
status: "complete" as const,
result: state.attachSourceReplyDeliveryMode({
queuedFinal,
counts,
...(state.routeState.sessionMetadataChangesForResult
? { sessionMetadataChanges: state.routeState.sessionMetadataChangesForResult }
: {}),
...(getObservedReplyDelivery() ? { observedReplyDelivery: true } : {}),
// Eligibility keys off settled visible delivery: a suppressed or cancelled
// final (including the core fallback itself) leaves channel-level recovery
// eligible, while any settled visible delivery clears it. An aborted or
// timed-out settle leaves delivery unresolved, and a fallback reported as
// delivered must not stay recoverable — either could double-send.
...(noVisibleReplyFallbackDirected &&
queuedSettleResult === "settled" &&
!turnLedger.hasVisibleDelivery() &&
!noVisibleReplyFallbackDelivered &&
!getObservedReplyDelivery() &&
!replyAcceptedByActiveRun &&
!emptyFinalAllowedAsSilent &&
!deliberateSilentTerminalReply &&
!pendingContinuation &&
!channelTransformSuppressed
? { noVisibleReplyFallbackEligible: true }
: {}),
...(noVisibleReplyFallbackDelivered ? { noVisibleReplyFallbackDelivered: true } : {}),
...(deliberateSilentTerminalReply ? { deliberateSilentTerminalReply: true } : {}),
...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}),
}),
result,
};
}
@@ -359,6 +359,7 @@ export async function gatherDispatchRequest(
dispatchHookDispatcher,
ensureDispatchReplyOperation,
failDispatchReplyOperation,
getAgentRunTerminalOutcome,
getDispatchAbortOperation,
getDispatchAbortSignal,
getDispatchReplyOperation,
@@ -497,6 +498,7 @@ export async function gatherDispatchRequest(
dispatchHookDispatcher,
ensureDispatchReplyOperation,
failDispatchReplyOperation,
getAgentRunTerminalOutcome,
getDispatchAbortOperation,
getDispatchAbortSignal,
getDispatchReplyOperation,
@@ -371,6 +371,7 @@ export function createDispatchReplyOperationCoordinator(params: {
const getQueuedFollowupAbortSignal = () =>
dispatchReplyOperation?.abortSignal ?? params.replyOptions?.abortSignal;
let observedReplyDelivery = false;
let agentRunTerminalOutcome: "completed" | "failed" | undefined;
const markObservedReplyDelivery = async () => {
if (observedReplyDelivery) {
return;
@@ -378,17 +379,13 @@ export function createDispatchReplyOperationCoordinator(params: {
observedReplyDelivery = true;
await params.replyOptions?.onObservedReplyDelivery?.();
};
const getReplyOptions = () => {
const getReplyOptions = (): DispatchFromConfigParams["replyOptions"] => {
const abortSignal = getDispatchAbortSignal();
const onAgentRunStart = params.messageAuditTerminal
? (runId: string) => {
params.messageAuditTerminal?.observeRunId(runId);
params.replyOptions?.onAgentRunStart?.(runId);
}
: undefined;
if (!abortSignal && !onAgentRunStart) {
return params.replyOptions;
}
const onAgentRunStart = (runId: string) => {
agentRunTerminalOutcome = "completed";
params.messageAuditTerminal?.observeRunId(runId);
params.replyOptions?.onAgentRunStart?.(runId);
};
return {
...params.replyOptions,
...(abortSignal
@@ -397,7 +394,7 @@ export function createDispatchReplyOperationCoordinator(params: {
queuedFollowupAbortSignal: getQueuedFollowupAbortSignal(),
}
: {}),
...(onAgentRunStart ? { onAgentRunStart } : {}),
onAgentRunStart,
...(dispatchReplyOperation ? { replyOperation: dispatchReplyOperation } : {}),
};
};
@@ -413,7 +410,10 @@ export function createDispatchReplyOperationCoordinator(params: {
}
};
const failDispatchReplyOperation = (error: unknown) => {
const failDispatchReplyOperation = (error: unknown, terminalOutcome?: "failed") => {
if (terminalOutcome === "failed" && agentRunTerminalOutcome === "completed") {
agentRunTerminalOutcome = "failed";
}
const completionBarrier = waitForDispatchLifecycleWorkAndDelivery();
void releasePreDispatchLifecycleAdmission(() => waitForReplyDispatcherIdle(params.dispatcher));
if (!dispatchReplyOperation) {
@@ -454,6 +454,7 @@ export function createDispatchReplyOperationCoordinator(params: {
turnLedger,
ensureDispatchReplyOperation,
failDispatchReplyOperation,
getAgentRunTerminalOutcome: () => agentRunTerminalOutcome,
getDispatchAbortOperation: () => dispatchAbortOperation,
getDispatchAbortSignal,
getDispatchReplyOperation: () => dispatchReplyOperation,
@@ -1,4 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { readAgentRunTerminalOutcome } from "../../channels/turn/agent-run-terminal-outcome.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -79,7 +80,10 @@ describe("dispatchReplyFromConfig terminal visible admission recovery", () => {
updatedAt: Date.now(),
};
const replyResolver = vi.fn(async () => ({ text: "telegram reply" }) satisfies ReplyPayload);
const replyResolver = vi.fn(async (_ctx, options) => {
options?.onAgentRunStart?.("successful-run");
return { text: "telegram reply" } satisfies ReplyPayload;
});
const dispatchParams = createVisibleDispatchParams(replyResolver);
const result = await dispatchReplyFromConfig(dispatchParams);
@@ -94,6 +98,7 @@ describe("dispatchReplyFromConfig terminal visible admission recovery", () => {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 0 },
});
expect(readAgentRunTerminalOutcome(result)).toBe("completed");
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(dispatchParams.dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
@@ -109,6 +114,7 @@ describe("dispatchReplyFromConfig terminal visible admission recovery", () => {
throw new Error("reply options required for partial recovery");
}
replyOperation = options.replyOperation;
options.onAgentRunStart?.("failed-run");
await options.onPartialReply?.({ text: "partial telegram reply" });
throw resolverError;
};
@@ -130,6 +136,7 @@ describe("dispatchReplyFromConfig terminal visible admission recovery", () => {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 0 },
});
expect(readAgentRunTerminalOutcome(result)).toBe("failed");
expect(dispatchParams.replyOptions.onPartialReply).toHaveBeenCalledWith({
text: "partial telegram reply",
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
readAgentRunTerminalOutcome,
recordAgentRunTerminalOutcome,
} from "./agent-run-terminal-outcome.js";
describe("agent run terminal outcome carrier", () => {
it("survives object spread without entering JSON", () => {
const result = {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
expect(recordAgentRunTerminalOutcome(result, "failed")).toBe(result);
expect(readAgentRunTerminalOutcome(result)).toBe("failed");
expect(
Object.getOwnPropertyDescriptor(result, Symbol.for("openclaw.agentRunTerminalOutcome")),
).toMatchObject({ enumerable: true, value: "failed" });
expect(readAgentRunTerminalOutcome({ ...result })).toBe("failed");
expect(JSON.stringify(result)).toBe(
JSON.stringify({ queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }),
);
});
it.each([
["undefined", undefined],
["null", null],
["primitive", "failed"],
["array", []],
["plain custom dispatch result", { agentRunTerminalOutcome: "failed" }],
[
"invalid private carrier value",
{ [Symbol.for("openclaw.agentRunTerminalOutcome")]: "cancelled" },
],
])("rejects %s", (_label, value) => {
expect(readAgentRunTerminalOutcome(value)).toBeUndefined();
});
});
@@ -0,0 +1,22 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
export type AgentRunTerminalOutcome = "completed" | "failed";
const AGENT_RUN_TERMINAL_OUTCOME: unique symbol = Symbol.for(
"openclaw.agentRunTerminalOutcome",
) as never;
export function recordAgentRunTerminalOutcome<T extends object>(
result: T,
outcome: AgentRunTerminalOutcome,
): T {
return Object.assign(result, { [AGENT_RUN_TERMINAL_OUTCOME]: outcome });
}
export function readAgentRunTerminalOutcome(result: unknown): AgentRunTerminalOutcome | undefined {
const outcome =
isRecord(result) && Object.hasOwn(result, AGENT_RUN_TERMINAL_OUTCOME)
? Reflect.get(result, AGENT_RUN_TERMINAL_OUTCOME)
: undefined;
return outcome === "completed" || outcome === "failed" ? outcome : undefined;
}
@@ -12,6 +12,10 @@ import { resetDiagnosticEventsForTest } from "../../infra/diagnostic-events.js";
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
import { outboundMessageIdentities } from "../message/outbound-echo-state.js";
import type { RecordInboundSession } from "../session.types.js";
import {
readAgentRunTerminalOutcome,
recordAgentRunTerminalOutcome,
} from "./agent-run-terminal-outcome.js";
import { hasVisibleChannelTurnDispatch } from "./dispatch-result.js";
import { dispatchAssembledChannelTurn, dispatchRoutedChannelTurn } from "./lifecycle.js";
import type { ChannelDeliveryInfo, ChannelTurnResult } from "./types.js";
@@ -495,7 +499,10 @@ describe("channel turn delivery", () => {
dispatchReplyWithRoutedChannelDispatcherCore.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "deliver me" }, { kind: "block" });
await params.dispatcherOptions.deliver({ text: "cancel me" }, { kind: "final" });
return { queuedFinal: true, counts: { tool: 0, block: 1, final: 1 } };
return recordAgentRunTerminalOutcome(
{ queuedFinal: true, counts: { tool: 0, block: 1, final: 1 } },
"failed",
);
});
const result = await dispatchRoutedChannelTurn({
@@ -515,6 +522,7 @@ describe("channel turn delivery", () => {
counts: { tool: 0, block: 1, final: 0 },
});
expect(hasVisibleChannelTurnDispatch(result.dispatchResult)).toBe(true);
expect(readAgentRunTerminalOutcome(result.dispatchResult)).toBe("failed");
});
it("delegates routed hybrid delivery to the provider message hook owner", async () => {
+5
View File
@@ -40,6 +40,11 @@ import type {
RunChannelTurnParams,
} from "../channels/turn/types.js";
export {
readAgentRunTerminalOutcome,
type AgentRunTerminalOutcome,
} from "../channels/turn/agent-run-terminal-outcome.js";
export {
createInboundDebouncer,
resolveInboundDebounceMs,