From b350f76484ad228fcb711ebb769eb2f60d4657fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 15:26:44 -0700 Subject: [PATCH] fix(channels): preserve failed agent run reactions (#122009) --- .../channel-inbound.json | 2 +- docs/plugins/sdk-channel-plugins.md | 8 +++ .../message-handler.process.ack.test.ts | 22 +++++++ .../message-handler.process.test-harness.ts | 4 ++ .../src/monitor/message-handler.process.ts | 14 ++--- .../event-handler.inbound-context.test.ts | 41 +++++++++++- .../signal/src/monitor/event-handler.ts | 6 +- .../dispatch.preview-fallback.test.ts | 31 ++++++++++ .../src/monitor/message-handler/dispatch.ts | 7 ++- .../telegram/src/bot-message-dispatch-turn.ts | 2 + ...e-dispatch.draft-failures-progress.test.ts | 42 ++++++++++++- .../telegram/src/bot-message-dispatch.ts | 3 +- .../src/bot-message-dispatch.types.ts | 1 + .../monitor/inbound-dispatch.test.ts | 57 +++++++++++++++++ .../auto-reply/monitor/inbound-dispatch.ts | 6 +- scripts/plugin-sdk-surface-report.mts | 6 +- ...from-config.abort-and-dedupe.test-utils.ts | 4 +- .../reply/dispatch-from-config.execute.ts | 2 +- .../reply/dispatch-from-config.finalize.ts | 62 ++++++++++--------- .../reply/dispatch-from-config.gather.ts | 2 + .../reply/dispatch-from-config.lifecycle.ts | 25 ++++---- ...atch-from-config.terminal-recovery.test.ts | 9 ++- .../turn/agent-run-terminal-outcome.test.ts | 38 ++++++++++++ .../turn/agent-run-terminal-outcome.ts | 22 +++++++ .../turn/run-channel-turn.delivery.test.ts | 10 ++- src/plugin-sdk/channel-inbound.ts | 5 ++ 26 files changed, 369 insertions(+), 62 deletions(-) create mode 100644 src/channels/turn/agent-run-terminal-outcome.test.ts create mode 100644 src/channels/turn/agent-run-terminal-outcome.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 3079e37bcf91..eb4e80659630 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"39d7eea281be7a268c1878155930034027f73ae6404622ab09041300bda41991","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"71e2548711edb5372870a7afcef25547aae3d2484578c88610749e90ebe5f244","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 0108eeb28c02..84a7612b489e 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -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 diff --git a/extensions/discord/src/monitor/message-handler.process.ack.test.ts b/extensions/discord/src/monitor/message-handler.process.ack.test.ts index a4ee6bfb632e..36dafc0ec6e5 100644 --- a/extensions/discord/src/monitor/message-handler.process.ack.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.ack.test.ts @@ -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) => { diff --git a/extensions/discord/src/monitor/message-handler.process.test-harness.ts b/extensions/discord/src/monitor/message-handler.process.test-harness.ts index 351655d6520c..f22cd635bccb 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-harness.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-harness.ts @@ -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>(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(); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index ff55bc222e7a..43f917f329cb 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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 }); } diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index d74913f9d9ff..22ae08a7fb25 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -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; }; @@ -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[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({ diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 8a21c94f0b3f..e0386003bf45 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -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)}`); }); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 07a2f10191d9..c178257db9ed 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -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[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({ diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index cdee1efd5e62..90f1ff6b0890 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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> = {}; 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(); diff --git a/extensions/telegram/src/bot-message-dispatch-turn.ts b/extensions/telegram/src/bot-message-dispatch-turn.ts index fa9c2ee2efdf..8ecc984ead5d 100644 --- a/extensions/telegram/src/bot-message-dispatch-turn.ts +++ b/extensions/telegram/src/bot-message-dispatch-turn.ts @@ -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) { diff --git a/extensions/telegram/src/bot-message-dispatch.draft-failures-progress.test.ts b/extensions/telegram/src/bot-message-dispatch.draft-failures-progress.test.ts index 662933a40fbc..278dbebb5195 100644 --- a/extensions/telegram/src/bot-message-dispatch.draft-failures-progress.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.draft-failures-progress.test.ts @@ -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", + ), + ); }, ); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index db222942b2e5..eb5a39cc3b25 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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", }, diff --git a/extensions/telegram/src/bot-message-dispatch.types.ts b/extensions/telegram/src/bot-message-dispatch.types.ts index 3bc92c8216b4..6f0c7fa0aea1 100644 --- a/extensions/telegram/src/bot-message-dispatch.types.ts +++ b/extensions/telegram/src/bot-message-dispatch.types.ts @@ -246,6 +246,7 @@ export type TelegramDispatchTurn = TelegramDispatchTurnConfig & TelegramDeliveryStateSlice & TelegramReplyStateSlice & { queuedFinal: boolean; + agentRunFailed?: boolean; noVisibleReplyFallbackEligible: boolean; suppressSilentReplyFallback: boolean; hadErrorReplyFailureOrSkip: boolean; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index f9637a569a8a..c62699605091 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -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>( async () => null, ), + readAgentRunTerminalOutcomeMock: vi.fn(), sourceReplyDeliveryModeContexts: [] as unknown[], })); +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readAgentRunTerminalOutcome: readAgentRunTerminalOutcomeMock, + }; +}); + vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => { const actual = await importOriginal(); 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(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 02fb65f12bd8..e86b5a5e9d50 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -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) { diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 09370ef250d0..7ecad6aec32a 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -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( diff --git a/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts index 83f39640db66..60f6bd1ef9c4 100644 --- a/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts @@ -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.", }); diff --git a/src/auto-reply/reply/dispatch-from-config.execute.ts b/src/auto-reply/reply/dispatch-from-config.execute.ts index 7f5ab63acd28..5c187a236b08 100644 --- a/src/auto-reply/reply/dispatch-from-config.execute.ts +++ b/src/auto-reply/reply/dispatch-from-config.execute.ts @@ -622,7 +622,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) ) { throw error; } - failDispatchReplyOperation(error); + failDispatchReplyOperation(error, "failed"); return buildTerminalAgentRunFailureReplyPayload({ visibleReplyDelivered: true, sessionCtx: ctx, diff --git a/src/auto-reply/reply/dispatch-from-config.finalize.ts b/src/auto-reply/reply/dispatch-from-config.finalize.ts index c427a243cb97..188d3f22cb92 100644 --- a/src/auto-reply/reply/dispatch-from-config.finalize.ts +++ b/src/auto-reply/reply/dispatch-from-config.finalize.ts @@ -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, }; } diff --git a/src/auto-reply/reply/dispatch-from-config.gather.ts b/src/auto-reply/reply/dispatch-from-config.gather.ts index 77c2c1ff5a35..f4f1f51a5319 100644 --- a/src/auto-reply/reply/dispatch-from-config.gather.ts +++ b/src/auto-reply/reply/dispatch-from-config.gather.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-from-config.lifecycle.ts b/src/auto-reply/reply/dispatch-from-config.lifecycle.ts index 8e6c06bcffc8..993fa31baeea 100644 --- a/src/auto-reply/reply/dispatch-from-config.lifecycle.ts +++ b/src/auto-reply/reply/dispatch-from-config.lifecycle.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts index 93d86884ba00..d3eac4fc8521 100644 --- a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts @@ -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", }); diff --git a/src/channels/turn/agent-run-terminal-outcome.test.ts b/src/channels/turn/agent-run-terminal-outcome.test.ts new file mode 100644 index 000000000000..a7213870504e --- /dev/null +++ b/src/channels/turn/agent-run-terminal-outcome.test.ts @@ -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(); + }); +}); diff --git a/src/channels/turn/agent-run-terminal-outcome.ts b/src/channels/turn/agent-run-terminal-outcome.ts new file mode 100644 index 000000000000..ae0613a3551b --- /dev/null +++ b/src/channels/turn/agent-run-terminal-outcome.ts @@ -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( + 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; +} diff --git a/src/channels/turn/run-channel-turn.delivery.test.ts b/src/channels/turn/run-channel-turn.delivery.test.ts index a382d5c21a56..46689587afef 100644 --- a/src/channels/turn/run-channel-turn.delivery.test.ts +++ b/src/channels/turn/run-channel-turn.delivery.test.ts @@ -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 () => { diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 6082c7e526b0..e93a2ed0706a 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -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,