mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(channels): preserve failed agent run reactions (#122009)
This commit is contained in:
committed by
GitHub
parent
285e58e5ef
commit
b350f76484
@@ -1 +1 @@
|
||||
{"contentHash":"39d7eea281be7a268c1878155930034027f73ae6404622ab09041300bda41991","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
{"contentHash":"71e2548711edb5372870a7afcef25547aae3d2484578c88610749e90ebe5f244","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user