mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user