Surface Codex usage-limit reset details in chat replies (#77557)

* fix(codex): surface usage limit reset details

* fix(codex): satisfy extension lint

* fix: surface codex runtime failures in tool-only replies
This commit is contained in:
pashpashpash
2026-05-04 17:00:39 -07:00
committed by GitHub
parent 306a582294
commit b2c3202a15
26 changed files with 1187 additions and 103 deletions

View File

@@ -50,6 +50,12 @@ export type ReplyPayload = {
export type ReplyPayloadMetadata = {
assistantMessageIndex?: number;
/**
* Internal OpenClaw notices generated after a runtime/provider failure are
* not assistant source replies. Dispatch may deliver them even when normal
* assistant source replies are message-tool-only; sendPolicy deny still wins.
*/
deliverDespiteSourceReplySuppression?: boolean;
};
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();
@@ -66,3 +72,14 @@ export function setReplyPayloadMetadata<T extends object>(
export function getReplyPayloadMetadata(payload: object): ReplyPayloadMetadata | undefined {
return replyPayloadMetadata.get(payload);
}
export function copyReplyPayloadMetadata<T extends object>(source: object, payload: T): T {
const metadata = getReplyPayloadMetadata(source);
return metadata ? setReplyPayloadMetadata(payload, metadata) : payload;
}
export function markReplyPayloadForSourceSuppressionDelivery<T extends object>(payload: T): T {
return setReplyPayloadMetadata(payload, {
deliverDespiteSourceReplySuppression: true,
});
}

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import type { TemplateContext } from "../templating.js";
import { createTestFollowupRun } from "./agent-runner.test-fixtures.js";
import type { QueueSettings } from "./queue.js";
@@ -201,6 +202,26 @@ describe("runReplyAgent runtime config", () => {
);
});
it("surfaces known pre-run Codex usage-limit failures instead of dropping the reply", async () => {
const { replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: false,
isActive: false,
});
const codexMessage =
"You've reached your Codex subscription usage limit. Codex did not return a reset time for this limit. Run /codex account for current usage details.";
runPreflightCompactionIfNeededMock.mockRejectedValue(new Error(codexMessage));
runMemoryFlushIfNeededMock.mockResolvedValue(undefined);
const result = await runReplyAgent(replyParams);
expect(result).toMatchObject({
text: `⚠️ ${codexMessage}`,
});
expect(result ? getReplyPayloadMetadata(result) : undefined).toMatchObject({
deliverDespiteSourceReplySuppression: true,
});
});
it("does not resolve secrets before the enqueue-followup queue path", async () => {
const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: true,

View File

@@ -3,6 +3,7 @@ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-erro
import type { SessionEntry } from "../../config/sessions.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import type { TemplateContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -88,7 +89,8 @@ vi.mock("../../agents/pi-embedded-helpers.js", () => ({
isBillingErrorMessage: () => false,
isLikelyContextOverflowError: () => false,
isOverloadedErrorMessage: (message: string) => /overloaded|capacity/i.test(message),
isRateLimitErrorMessage: () => false,
isRateLimitErrorMessage: (message: string) =>
/rate.limit|too many requests|429|usage limit/i.test(message),
isTransientHttpError: () => false,
sanitizeUserFacingText: (text?: string) => text ?? "",
}));
@@ -2024,6 +2026,98 @@ describe("runAgentTurnWithFallback", () => {
}
});
it("surfaces Codex usage-limit reset details for pure fallback exhaustion", async () => {
const codexMessage =
"You've reached your Codex subscription usage limit. Next reset in 42 minutes (2026-05-04T21:34:00.000Z). Run /codex account for current usage details.";
state.runWithModelFallbackMock.mockRejectedValueOnce(
Object.assign(new Error(`All models failed (1): openai/gpt-5.5: ${codexMessage}`), {
name: "FallbackSummaryError",
attempts: [
{
provider: "openai",
model: "gpt-5.5",
error: codexMessage,
reason: "rate_limit",
},
],
soonestCooldownExpiry: null,
}),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "telegram",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(`⚠️ ${codexMessage}`);
expect(result.payload.text).not.toContain("All models failed");
expect(getReplyPayloadMetadata(result.payload)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
});
}
});
it("surfaces direct Codex usage-limit errors when fallback does not wrap one attempt", async () => {
const codexMessage =
"You've reached your Codex subscription usage limit. Codex did not return a reset time for this limit. Run /codex account for current usage details.";
state.runWithModelFallbackMock.mockRejectedValueOnce(new Error(codexMessage));
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "telegram",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(`⚠️ ${codexMessage}`);
expect(getReplyPayloadMetadata(result.payload)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
});
}
});
it("surfaces billing guidance for pure billing cooldown fallback exhaustion", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(
Object.assign(

View File

@@ -59,6 +59,7 @@ import {
} from "../../utils/message-channel.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import {
@@ -300,6 +301,10 @@ function rollbackFallbackSelectionStateIfUnchanged(
* Includes a countdown when the soonest cooldown expiry is known.
*/
function buildRateLimitCooldownMessage(err: unknown): string {
const codexUsageLimitMessage = extractCodexUsageLimitErrorMessage(err);
if (codexUsageLimitMessage) {
return codexUsageLimitMessage;
}
if (!isFallbackSummaryError(err)) {
return "⚠️ All models are temporarily rate-limited. Please try again in a few minutes.";
}
@@ -316,6 +321,44 @@ function buildRateLimitCooldownMessage(err: unknown): string {
return "⚠️ All models are temporarily rate-limited. Please try again in a few minutes.";
}
function extractCodexUsageLimitErrorMessage(err: unknown): string | undefined {
if (isFallbackSummaryError(err)) {
for (const attempt of err.attempts) {
const message = extractCodexUsageLimitMessage(attempt.error);
if (message) {
return `⚠️ ${message}`;
}
}
return undefined;
}
const message = extractCodexUsageLimitMessage(formatErrorMessage(err));
return message ? `⚠️ ${message}` : undefined;
}
function extractCodexUsageLimitMessage(text: string): string | undefined {
const markers = [
"You've reached your Codex subscription usage limit.",
"Codex usage limit reached.",
];
const markerIndex = markers
.map((marker) => text.indexOf(marker))
.filter((index) => index >= 0)
.toSorted((left, right) => left - right)[0];
if (markerIndex === undefined) {
return undefined;
}
const message = sanitizeUserFacingText(text.slice(markerIndex), { errorContext: true })
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.join(" ")
.trim();
if (!message) {
return undefined;
}
return message.length > 500 ? `${message.slice(0, 497)}...` : message;
}
function isPureTransientRateLimitSummary(err: unknown): boolean {
return (
isFallbackSummaryError(err) &&
@@ -459,6 +502,74 @@ function buildExternalRunFailureReply(
};
}
function markAgentRunFailureReplyPayload<T extends ReplyPayload>(payload: T): T {
return markReplyPayloadForSourceSuppressionDelivery(payload);
}
export function buildKnownAgentRunFailureReplyPayload(params: {
err: unknown;
sessionCtx: TemplateContext;
resolvedVerboseLevel: VerboseLevel | undefined;
}): ReplyPayload | undefined {
const message = formatErrorMessage(params.err);
const isFallbackSummary = isFallbackSummaryError(params.err);
const isBilling = isFallbackSummary
? isPureBillingSummary(params.err)
: isBillingErrorMessage(message);
if (isBilling) {
return markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: BILLING_ERROR_USER_MESSAGE,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
}),
});
}
const isPureTransientSummary = isFallbackSummary
? isPureTransientRateLimitSummary(params.err)
: false;
const isRateLimit = isFallbackSummary ? isPureTransientSummary : isRateLimitErrorMessage(message);
const rateLimitOrOverloadedCopy =
!isFallbackSummary || isPureTransientSummary
? formatRateLimitOrOverloadedErrorCopy(message)
: undefined;
if (isRateLimit && !isOverloadedErrorMessage(message)) {
return markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: buildRateLimitCooldownMessage(params.err),
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
}),
});
}
if (rateLimitOrOverloadedCopy) {
return markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: rateLimitOrOverloadedCopy,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
}),
});
}
const externalRunFailureReply = buildExternalRunFailureReply(message, {
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
});
if (externalRunFailureReply.isGenericRunnerFailure) {
return undefined;
}
return markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: externalRunFailureReply.text,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
}),
});
}
const CONTEXT_OVERFLOW_RESET_HINT =
"\n\nTo prevent this, increase your compaction buffer by setting " +
"`agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.";
@@ -1771,7 +1882,7 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", embeddedError);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: buildContextOverflowRecoveryText({
cfg: runtimeConfig,
agentId: params.followupRun.run.agentId,
@@ -1779,7 +1890,7 @@ export async function runAgentTurnWithFallback(params: {
primaryModel: params.followupRun.run.model,
activeSessionEntry: params.getActiveSessionEntry(),
}),
},
}),
};
}
if (embeddedError?.kind === "role_ordering") {
@@ -1788,9 +1899,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", embeddedError);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
},
}),
};
}
}
@@ -1820,13 +1931,13 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: switchErrorText,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: !shouldSurfaceToControlUi,
}),
},
}),
};
}
params.followupRun.run.provider = err.provider;
@@ -1852,9 +1963,9 @@ export async function runAgentTurnWithFallback(params: {
if (isReplyOperationRestartAbort(params.replyOperation)) {
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: buildRestartLifecycleReplyText(),
},
}),
};
}
@@ -1872,9 +1983,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("gateway_draining", restartLifecycleError);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: buildRestartLifecycleReplyText(),
},
}),
};
}
@@ -1882,9 +1993,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("command_lane_cleared", restartLifecycleError);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: buildRestartLifecycleReplyText(),
},
}),
};
}
@@ -1897,7 +2008,7 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: buildContextOverflowRecoveryText({
duringCompaction: true,
cfg: runtimeConfig,
@@ -1906,7 +2017,7 @@ export async function runAgentTurnWithFallback(params: {
primaryModel: params.followupRun.run.model,
activeSessionEntry: params.getActiveSessionEntry(),
}),
},
}),
};
}
if (isRoleOrderingError) {
@@ -1915,9 +2026,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
},
}),
};
}
}
@@ -1962,9 +2073,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("session_corruption_reset", err);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!",
},
}),
};
}
@@ -2036,9 +2147,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: userVisibleFallbackText,
},
}),
};
}
}
@@ -2056,9 +2167,9 @@ export async function runAgentTurnWithFallback(params: {
params.replyOperation?.fail("run_failed", finalEmbeddedError);
return {
kind: "final",
payload: {
payload: markAgentRunFailureReplyPayload({
text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
},
}),
};
}
}
@@ -2093,10 +2204,10 @@ export async function runAgentTurnWithFallback(params: {
: undefined;
if (formattedErrorCandidate) {
runResult.payloads = [
{
markAgentRunFailureReplyPayload({
text: formattedErrorCandidate,
isError: true,
},
}),
];
}
}

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
getReplyPayloadMetadata,
markReplyPayloadForSourceSuppressionDelivery,
} from "../reply-payload.js";
import { buildReplyPayloads } from "./agent-runner-payloads.js";
const baseParams = {
@@ -47,6 +51,28 @@ describe("buildReplyPayloads media filter integration", () => {
expect(replyPayloads[0]?.text).toBe("Before\n\n\nAfter");
});
it("preserves internal delivery metadata through final payload normalization", async () => {
const payload = markReplyPayloadForSourceSuppressionDelivery({
text: "⚠️ API rate limit reached.\n[[reply_to_current]]",
});
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [payload],
replyToMode: "all",
currentMessageId: "msg-1",
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]).toMatchObject({
text: "⚠️ API rate limit reached.",
replyToId: "msg-1",
});
expect(getReplyPayloadMetadata(replyPayloads[0])).toMatchObject({
deliverDespiteSourceReplySuppression: true,
});
});
it("strips media URL from payload when in messagingToolSentMediaUrls", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,

View File

@@ -5,6 +5,7 @@ import { logVerbose } from "../../globals.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { stripLegacyBracketToolCallBlocks } from "../../shared/text/assistant-visible-text.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js";
@@ -35,15 +36,16 @@ async function normalizeReplyPayloadMedia(params: {
}
try {
return await params.normalizeMediaPaths(params.payload);
const normalized = await params.normalizeMediaPaths(params.payload);
return copyReplyPayloadMetadata(params.payload, normalized);
} catch (err) {
logVerbose(`reply payload media normalization failed: ${String(err)}`);
return {
return copyReplyPayloadMetadata(params.payload, {
...params.payload,
mediaUrl: undefined,
mediaUrls: undefined,
audioAsVoice: false,
};
});
}
}
@@ -102,7 +104,7 @@ function sanitizeHeartbeatPayload(payload: ReplyPayload): ReplyPayload {
return payload;
}
logVerbose("Stripped legacy tool-call block from heartbeat reply");
return { ...payload, text: cleaned };
return copyReplyPayloadMetadata(payload, { ...payload, text: cleaned });
}
export async function buildReplyPayloads(params: {
@@ -139,7 +141,7 @@ export async function buildReplyPayloads(params: {
}
if (!text || !text.includes("HEARTBEAT_OK")) {
return [{ ...payload, text }];
return [copyReplyPayloadMetadata(payload, { ...payload, text })];
}
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
@@ -150,7 +152,7 @@ export async function buildReplyPayloads(params: {
if (stripped.shouldSkip && !hasMedia) {
return [];
}
return [{ ...payload, text: stripped.text }];
return [copyReplyPayloadMetadata(payload, { ...payload, text: stripped.text })];
});
const replyTaggedPayloads = (
@@ -275,20 +277,20 @@ export async function buildReplyPayloads(params: {
if (!reply.trimmedText) {
return payload;
}
const textOnlyPayload = {
const textOnlyPayload = copyReplyPayloadMetadata(payload, {
...payload,
mediaUrl: undefined,
mediaUrls: undefined,
audioAsVoice: undefined,
};
});
if (!params.blockReplyPipeline?.hasSentPayload(textOnlyPayload)) {
return payload;
}
return {
return copyReplyPayloadMetadata(payload, {
...payload,
text: undefined,
audioAsVoice: payload.audioAsVoice || undefined,
};
});
};
const contentSuppressedPayloads = shouldDropFinalPayloads
? dedupedPayloads.flatMap((payload) => preserveUnsentMediaAfterBlockStream(payload) ?? [])

View File

@@ -38,11 +38,15 @@ import {
buildFallbackNotice,
resolveFallbackTransition,
} from "../fallback-state.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { runAgentTurnWithFallback } from "./agent-runner-execution.js";
import {
buildKnownAgentRunFailureReplyPayload,
runAgentTurnWithFallback,
} from "./agent-runner-execution.js";
import {
createShouldEmitToolOutput,
createShouldEmitToolResult,
@@ -1141,9 +1145,9 @@ export async function runReplyAgent(params: {
} catch (error) {
if (error instanceof ReplyRunAlreadyActiveError) {
typing.cleanup();
return {
return markReplyPayloadForSourceSuppressionDelivery({
text: "⚠️ Previous run is still shutting down. Please try again in a moment.",
};
});
}
throw error;
}
@@ -1883,24 +1887,39 @@ export async function runReplyAgent(params: {
replyOperation.result?.kind === "aborted" &&
replyOperation.result.code === "aborted_for_restart"
) {
return returnWithQueuedFollowupDrain({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
});
return returnWithQueuedFollowupDrain(
markReplyPayloadForSourceSuppressionDelivery({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
}),
);
}
if (replyOperation.result?.kind === "aborted") {
return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN });
}
if (error instanceof GatewayDrainingError) {
replyOperation.fail("gateway_draining", error);
return returnWithQueuedFollowupDrain({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
});
return returnWithQueuedFollowupDrain(
markReplyPayloadForSourceSuppressionDelivery({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
}),
);
}
if (error instanceof CommandLaneClearedError) {
replyOperation.fail("command_lane_cleared", error);
return returnWithQueuedFollowupDrain({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
});
return returnWithQueuedFollowupDrain(
markReplyPayloadForSourceSuppressionDelivery({
text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
}),
);
}
const knownFailurePayload = buildKnownAgentRunFailureReplyPayload({
err: error,
sessionCtx,
resolvedVerboseLevel,
});
if (knownFailurePayload) {
replyOperation.fail("run_failed", error);
return returnWithQueuedFollowupDrain(knownFailurePayload);
}
replyOperation.fail("run_failed", error);
// Keep the followup queue moving even when an unexpected exception escapes

View File

@@ -25,7 +25,7 @@ import {
} from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { setReplyPayloadMetadata, type GetReplyOptions, type ReplyPayload } from "../types.js";
import type { ReplyDispatcher } from "./reply-dispatcher.js";
import { buildTestCtx } from "./test-ctx.js";
@@ -4162,6 +4162,7 @@ describe("before_dispatch hook", () => {
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => {
beforeEach(() => {
resetInboundDedupe();
sessionStoreMocks.currentEntry = undefined;
sessionBindingMocks.resolveByConversation.mockReset();
sessionBindingMocks.resolveByConversation.mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
@@ -4579,6 +4580,72 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
);
});
it("delivers marked runtime failure notices in message-tool-only mode", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const failureNotice = setReplyPayloadMetadata(
{ text: "⚠️ You've reached your Codex subscription usage limit." },
{ deliverDespiteSourceReplySuppression: true },
);
const replyResolver = vi.fn(async () => failureNotice satisfies ReplyPayload);
const ctx = buildTestCtx({ SessionKey: "test:session" });
const result = await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyResolver,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(failureNotice);
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
});
it("does not deliver marked runtime failure notices when sendPolicy denies delivery", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "deny",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(
async () =>
setReplyPayloadMetadata(
{ text: "⚠️ You've reached your Codex subscription usage limit." },
{ deliverDespiteSourceReplySuppression: true },
) satisfies ReplyPayload,
);
const ctx = buildTestCtx({ SessionKey: "test:session" });
const result = await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyResolver,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("defaults group/channel turns to message-tool-only source delivery", async () => {
setNoAbort();
const dispatcher = createDispatcher();

View File

@@ -779,6 +779,7 @@ export async function dispatchReplyFromConfig(
sourceReplyDeliveryMode,
suppressAutomaticSourceDelivery,
suppressDelivery,
sendPolicyDenied,
deliverySuppressionReason,
suppressHookUserDelivery,
suppressHookReplyLifecycle,
@@ -1501,29 +1502,36 @@ export async function dispatchReplyFromConfig(
let routedFinalCount = 0;
let attemptedFinalDelivery = false;
let finalDeliveryFailed = false;
const shouldDeliverDespiteSourceReplySuppression = (reply: ReplyPayload) =>
suppressAutomaticSourceDelivery &&
!sendPolicyDenied &&
getReplyPayloadMetadata(reply)?.deliverDespiteSourceReplySuppression === true;
for (const reply of replies) {
// Suppress reasoning payloads from channel delivery — channels using this
// generic dispatch path do not have a dedicated reasoning lane.
if (reply.isReasoning === true) {
continue;
}
if (suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(reply)) {
continue;
}
attemptedFinalDelivery = true;
const finalReply = await sendFinalPayload(reply);
queuedFinal = finalReply.queuedFinal || queuedFinal;
routedFinalCount += finalReply.routedFinalCount;
if (!finalReply.queuedFinal && finalReply.routedFinalCount === 0) {
finalDeliveryFailed = true;
}
}
if (attemptedFinalDelivery && !finalDeliveryFailed) {
await clearPendingFinalDeliveryAfterSuccess({
storePath: sessionStoreEntry.storePath,
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
});
}
if (!suppressDelivery) {
for (const reply of replies) {
// Suppress reasoning payloads from channel delivery — channels using this
// generic dispatch path do not have a dedicated reasoning lane.
if (reply.isReasoning === true) {
continue;
}
attemptedFinalDelivery = true;
const finalReply = await sendFinalPayload(reply);
queuedFinal = finalReply.queuedFinal || queuedFinal;
routedFinalCount += finalReply.routedFinalCount;
if (!finalReply.queuedFinal && finalReply.routedFinalCount === 0) {
finalDeliveryFailed = true;
}
}
if (attemptedFinalDelivery && !finalDeliveryFailed) {
await clearPendingFinalDeliveryAfterSuccess({
storePath: sessionStoreEntry.storePath,
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
});
}
const ttsMode = resolveConfiguredTtsMode(cfg, {
agentId: sessionAgentId,
channelId: deliveryChannel,

View File

@@ -1,6 +1,6 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "../../globals.js";
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { BlockReplyContext, ReplyPayload, ReplyThreadingPolicy } from "../types.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
@@ -48,7 +48,7 @@ export function normalizeReplyPayloadDirectives(params: {
const mediaUrl = params.payload.mediaUrl ?? parsed?.mediaUrl ?? mediaUrls?.[0];
return {
payload: {
payload: copyReplyPayloadMetadata(params.payload, {
...params.payload,
text,
mediaUrls,
@@ -57,16 +57,11 @@ export function normalizeReplyPayloadDirectives(params: {
replyToTag: params.payload.replyToTag || parsed?.replyToTag,
replyToCurrent: params.payload.replyToCurrent || parsed?.replyToCurrent,
audioAsVoice: Boolean(params.payload.audioAsVoice || parsed?.audioAsVoice),
},
}),
isSilent: parsed?.isSilent ?? false,
};
}
function carryReplyPayloadMetadata(source: ReplyPayload, target: ReplyPayload): ReplyPayload {
const metadata = getReplyPayloadMetadata(source);
return metadata ? setReplyPayloadMetadata(target, metadata) : target;
}
async function sendDirectBlockReply(params: {
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
directlySentBlockKeys: Set<string>;
@@ -130,7 +125,7 @@ export function createBlockReplyDeliveryHandler(params: {
const mediaNormalizedPayload = params.normalizeMediaPaths
? await params.normalizeMediaPaths(normalized.payload)
: normalized.payload;
const blockPayload = carryReplyPayloadMetadata(
const blockPayload = copyReplyPayloadMetadata(
payload,
params.applyReplyToMode(mediaNormalizedPayload),
);

View File

@@ -1,6 +1,7 @@
import type { ReplyToMode } from "../../config/types.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
@@ -42,27 +43,30 @@ function resolveReplyThreadingForPayload(params: {
!implicitReplyToId ||
!allowImplicitReplyToCurrentMessage
? params.payload
: { ...params.payload, replyToId: implicitReplyToId };
: copyReplyPayloadMetadata(params.payload, {
...params.payload,
replyToId: implicitReplyToId,
});
if (typeof resolved.text === "string" && resolved.text.includes("[[")) {
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
resolved.text,
currentMessageId,
);
resolved = {
resolved = copyReplyPayloadMetadata(resolved, {
...resolved,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? resolved.replyToId,
replyToTag: hasTag || resolved.replyToTag,
replyToCurrent: replyToCurrent || resolved.replyToCurrent,
};
});
}
if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) {
resolved = {
resolved = copyReplyPayloadMetadata(resolved, {
...resolved,
replyToId: currentMessageId,
};
});
}
return resolved;

View File

@@ -4,6 +4,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js";
import { isSingleUseReplyToMode } from "./reply-reference.js";
@@ -107,7 +108,7 @@ export function createReplyToModeFilter(
if (opts.allowExplicitReplyTagsWhenOff && isExplicit && !payload.isCompactionNotice) {
return payload;
}
return { ...payload, replyToId: undefined };
return copyReplyPayloadMetadata(payload, { ...payload, replyToId: undefined });
}
if (mode === "all") {
return payload;
@@ -119,7 +120,7 @@ export function createReplyToModeFilter(
if (payload.isCompactionNotice) {
return payload;
}
return { ...payload, replyToId: undefined };
return copyReplyPayloadMetadata(payload, { ...payload, replyToId: undefined });
}
// Compaction notices are transient status messages — they should be
// threaded (so they appear in-context), but they must not consume the

View File

@@ -4,5 +4,9 @@ export type {
ReplyThreadingPolicy,
TypingPolicy,
} from "./get-reply-options.types.js";
export { setReplyPayloadMetadata } from "./reply-payload.js";
export {
copyReplyPayloadMetadata,
markReplyPayloadForSourceSuppressionDelivery,
setReplyPayloadMetadata,
} from "./reply-payload.js";
export type { ReplyPayload } from "./reply-payload.js";

View File

@@ -5,6 +5,7 @@ import {
createHeartbeatToolResponsePayload,
type HeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
@@ -184,6 +185,44 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
});
});
it("delivers Codex runtime failure notices during Codex heartbeat message-tool mode", async () => {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createConfig({ tmpDir, storePath });
await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: TELEGRAM_GROUP,
agentHarnessId: "codex",
});
const usageLimitMessage =
"⚠️ You've reached your Codex subscription usage limit. Next reset in 42 minutes (2026-05-04T21:34:00.000Z). Run /codex account for current usage details.";
replySpy.mockResolvedValue(
markReplyPayloadForSourceSuppressionDelivery({
text: usageLimitMessage,
isError: true,
}),
);
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
const result = await runHeartbeatOnce({
cfg,
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
});
const calledOpts = replySpy.mock.calls[0]?.[1] as {
sourceReplyDeliveryMode?: string;
};
expect(result.status).toBe("ran");
expect(calledOpts.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(sendTelegram).toHaveBeenCalledTimes(1);
expect(sendTelegram).toHaveBeenCalledWith(
TELEGRAM_GROUP,
usageLimitMessage,
expect.any(Object),
);
});
});
it("uses the heartbeat response tool prompt for auto-selected Codex model sessions", async () => {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createConfig({