mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): render failover user copy from one reason-keyed module (#121717)
* refactor(agents): centralize failover user copy * refactor(agents): route failure callers through user copy * refactor(qa): carry typed reply failure markers * refactor(agents): keep failover copy import-light * refactor(agents): pass structured failure copy context * refactor(agents): isolate copy rendering from runtime state * refactor(agents): separate copy rendering from sanitization * refactor(agents): keep failover copy internals private * fix(qa-channel): type failure markers on bus sends * style(agents): brace failover copy conditions * test(qa-lab): avoid map spread in failure cases * chore(plugin-sdk): refresh failover closure hashes * fix(agents): preserve generic runner fallback * fix(qa): preserve text-only failure markers * test(qa): type failure delivery fixture
This commit is contained in:
committed by
GitHub
parent
96b0cf95ef
commit
6dc77a37d9
+17
-17
@@ -53,7 +53,7 @@
|
||||
{"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise<AbortAndDrainEmbeddedAgentRunResult>;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise<OpenClawAgentToolResult>; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise<AgentToolResult<unknown>>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"6fdbebb5c0470d28dd5089084531421c4806ebd35ae3426851f6f507f9eb1eda","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"6184663b7bda9bc246ccb9ec7457a0f594f11941b155f308511e239db901c916","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise<void>;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
{"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"}
|
||||
@@ -1097,7 +1097,7 @@
|
||||
{"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"}
|
||||
{"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"}
|
||||
{"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"}
|
||||
{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"}
|
||||
{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"}
|
||||
{"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"}
|
||||
{"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"}
|
||||
{"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"}
|
||||
@@ -1226,7 +1226,7 @@
|
||||
{"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"}
|
||||
{"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"}
|
||||
{"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"}
|
||||
{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"}
|
||||
{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"}
|
||||
{"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"}
|
||||
{"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"}
|
||||
{"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"}
|
||||
@@ -2354,7 +2354,7 @@
|
||||
{"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record<ReplyDispatchKind, number>;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"}
|
||||
{"closureHash":"9d503d05e408ddd1a1c373e3c4485b1f9acff4c240fdc241840a2cfa8b55c3a7","declaration":"export function runChannelInboundEvent<TRaw, TDispatchResult = DispatchFromConfigResult>(params: RunChannelTurnParams<TRaw, TDispatchResult, ChannelProviderOwnedMessageSendingDeliveryAdapter>): Promise<ChannelTurnResult<TDispatchResult>>;\nexport function runChannelInboundEvent<TRaw, TDispatchResult = DispatchFromConfigResult>(params: ChannelInboundEventRunnerParams<TRaw, TDispatchResult>): Promise<ChannelTurnResult<TDispatchResult>>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"}
|
||||
{"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply<TDispatchResult>(params: PreparedChannelTurn<TDispatchResult>): Promise<ChannelTurnResult<TDispatchResult>>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"}
|
||||
{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"}
|
||||
{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"}
|
||||
{"closureHash":"278dc8a410230012ddbc3552ec621a224e6f7c5dd4cd3904c943ac98dd4259e4","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"}
|
||||
{"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"}
|
||||
{"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"}
|
||||
@@ -3189,19 +3189,19 @@
|
||||
{"closureHash":"50394942e0a454c742e80a4f3c8739c80258b387ade498eb5aed66df887108be","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise<unknown>;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"863915a7e74c53eac4c897d3b5a51de85cfc85918db0bca4a36cf6daeeed63d9","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"28efffa08c777fe436dcbe325179c9382df7067408c7bcf6c61ec41ce57b6b6e","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"9cb63eefd2e5a70aea960bf17fcf570ef02a2d7b5afd5ad9451802964c653fcf","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise<string>; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"8783d96ff20b445457add8369c6ed8156038e7edb8fdc31fabeb92c8bcf5a9a2","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise<void>; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"681d30651dd7d6ea197c4c37718c2022da017ea6f538aad4a5399682d707ec78","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise<void>; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession<TTransport extends string, TMode extends string, TToolPolicy extends string>(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin<TTransport, TMode>; createdAt: string; }): MeetingSessionRecord<TTransport, TMode, { enabled: boolean; strategy: string; provider: string | undefined; model: string | undefined; transcriptionProvider: string | undefined; toolPolicy: TToolPolicy; }>;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"93c4bb70429a2739151e6f900e67bed81f3cd8f597811220e790341edb0d6008","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise<MeetingVoiceCallGatewayClient>; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"4fd4507ee880b0ef6d61d2978aeaa2367b1c8d87e6060ff17ae6125884713fac","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"ad7c8334bc5d6c66a3ecfc0e4ed2cb33170cb432cdf593b6ad68d238347c0ba6","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise<void>;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise<MeetingVoiceCallStatusResult>;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"612e8c45b04b7b8da15d6f1d56fa85a50b6e6f2b5c7792f02e04dd1fd1b135df","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise<MeetingVoiceCallJoinResult>;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"3b53c387db13f3dc8e599bb14f98b6a11ae44607afe97c951bef3ec4aa6a8c7b","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise<MeetingVoiceCallJoinResult>;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser<Session, Mode extends string, Health extends MeetingBrowserHealth, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter<Session, Mode, Health, Transcript>; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser<Session extends MeetingBrowserJoinSession<Mode>, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter<Session, Mode, Health, Transcript>; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
@@ -3212,8 +3212,8 @@
|
||||
{"closureHash":"56874c5b2ced5af647b1dff296c931f0d8b85b1318e98c741aa88b58a9c5c606","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise<MeetingBrowserNodeInfo>;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise<void>;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"3806e9a72af2d7de40e65e979771064b8142ef300826cc335dc6592999eeecef","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise<MeetingRealtimeAudioEngineHandle>;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"e946e36e2de5f85193a9940159cc43453cb9e94bec37e51edf2d3690359e396c","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise<void>; }): Promise<MeetingRealtimeAudioEngineHandle>;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"02dadbf91f221d8471e27a1b1744de46f291082e1bbfc79266c32e3c9b9a6e27","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise<MeetingRealtimeAudioEngineHandle>;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"c48350de1750bc99116c987a24f090435f745a06c90b855bd536d0edaad8c0d5","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise<void>; }): Promise<MeetingRealtimeAudioEngineHandle>;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
@@ -3245,7 +3245,7 @@
|
||||
{"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles<THealth extends MeetingBrowserHealth> = MeetingSessionRuntimeHandles<THealth>;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext<TSession extends MeetingSessionRecord<TTransport, TMode>, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext<TSession, TTransport, TMode, THealth, TTab>;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages<TSpeechBlockedReason extends string> = MeetingSessionRuntimeMessages<TSpeechBlockedReason>;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"05e0c2d4024d954e9d0c5b993a2eef1ec19f0ed76842a1e62489713121a51f0c","declaration":"export type MeetingSessionRuntimeOptions<TSession extends MeetingSessionRecord<TTransport, TMode>, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth<TManualReason, TSpeechBlockedReason>, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions<TSession, TRequest, TTransport, TMode, THealth, TTab, TManualReason, TSpeechBlockedReason>;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"736c01afcaad8e9c228ccfe8728a7323edf03583753cdf99aeef2cbd4646304b","declaration":"export type MeetingSessionRuntimeOptions<TSession extends MeetingSessionRecord<TTransport, TMode>, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth<TManualReason, TSpeechBlockedReason>, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions<TSession, TRequest, TTransport, TMode, THealth, TTab, TManualReason, TSpeechBlockedReason>;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
@@ -3259,9 +3259,9 @@
|
||||
{"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"}
|
||||
{"closureHash":"52f2a358bd777b737c9ff079edf5887b5d6b387ea9bb65acd98733f0fc95ed43","declaration":"export interface MeetingPlatformAdapter<Session, Mode extends string, Health extends MeetingBrowserHealth, Transcript extends MeetingTranscriptSnapshot, CreateParams = never, CreateResult = never, DialInParams = never, DialInPlan = never> extends MeetingPlatformAdapterContract<Session, Mode, Health, Transcript, CreateParams, CreateResult, DialInParams, DialInPlan> {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"}
|
||||
{"closureHash":"26f7c1ed34ea23dbf5e9f4e65f65c78c12cbae1864208a5f92c305da4c6f086b","declaration":"export interface MeetingPlatformAdapter<Session, Mode extends string, Health extends MeetingBrowserHealth, Transcript extends MeetingTranscriptSnapshot, CreateParams = never, CreateResult = never, DialInParams = never, DialInPlan = never> extends MeetingPlatformAdapterContract<Session, Mode, Health, Transcript, CreateParams, CreateResult, DialInParams, DialInPlan> {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"}
|
||||
{"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise<void>;\n writeOutput(audio: Buffer): Promise<void>;\n clearOutput(): Promise<void>;\n dispose(): Promise<void>;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"}
|
||||
{"closureHash":"368b6b83032d8c166d11282f85ce9b88c012cb8e13a9dbcf187175573aacc826","declaration":"export class MeetingSessionRuntime<TSession extends MeetingSessionRecord<TTransport, TMode>, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth<TManualReason, TSpeechBlockedReason>, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map<string, TSession>;\n readonly #sessionLeaves: Map<string, Promise<MeetingSessionLeaveResult<TSession>>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map<string, () => Promise<void>>;\n readonly #sessionSpeakers: Map<string, (instructions?: string) => void>;\n readonly #sessionHealth: Map<string, () => Partial<THealth>>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts<TSession>;\n readonly #transcriptStore: MeetingSessionTranscriptStore<TSession>;\n constructor(private readonly options: MeetingSessionRuntimeOptions<TSession, TRequest, TTransport, TMode, THealth, TTab, TManualReason, TSpeechBlockedReason>);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise<TranscriptsStartResult>;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise<TranscriptsStopResult>;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin<TTransport, TMode>): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise<boolean>;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise<void>;\n async refreshCaptionHealth(session: TSession): Promise<void>;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin<TTransport, TMode>): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise<boolean>;\n async #rollbackFailedJoinSession(session: TSession): Promise<void>;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise<void>;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles<THealth>): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"}
|
||||
{"closureHash":"ddae4750513c758c0272f195bb0279ac172a00bfe05584dd6afea15aa379576d","declaration":"export class MeetingSessionRuntime<TSession extends MeetingSessionRecord<TTransport, TMode>, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth<TManualReason, TSpeechBlockedReason>, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map<string, TSession>;\n readonly #sessionLeaves: Map<string, Promise<MeetingSessionLeaveResult<TSession>>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map<string, () => Promise<void>>;\n readonly #sessionSpeakers: Map<string, (instructions?: string) => void>;\n readonly #sessionHealth: Map<string, () => Partial<THealth>>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts<TSession>;\n readonly #transcriptStore: MeetingSessionTranscriptStore<TSession>;\n constructor(private readonly options: MeetingSessionRuntimeOptions<TSession, TRequest, TTransport, TMode, THealth, TTab, TManualReason, TSpeechBlockedReason>);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise<TranscriptsStartResult>;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise<TranscriptsStopResult>;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin<TTransport, TMode>): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise<boolean>;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise<void>;\n async refreshCaptionHealth(session: TSession): Promise<void>;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin<TTransport, TMode>): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise<MeetingSessionLeaveResult<TSession>>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise<boolean>;\n async #rollbackFailedJoinSession(session: TSession): Promise<void>;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise<void>;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles<THealth>): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"}
|
||||
{"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"}
|
||||
{"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"}
|
||||
{"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"}
|
||||
@@ -3655,10 +3655,10 @@
|
||||
{"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"}
|
||||
{"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"}
|
||||
{"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise<ModelCatalogEntry[]>;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"1b91fb602c859027c6c32f35b1d6db992182644f0b31d5dc723c4dc68b4ff341","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters<typeof resolvePluginProviders>[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"7b8b2d050f3124b21a3ce4138f8621bc9148fb38cbb485906e2e17126a3d217e","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"fe7c0743b550b192b24fe084c30ddf85a36a0e0331894802170ab4e1bf099586","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick<PluginMetadataSnapshot, \"owners\" | \"manifestRegistry\" | \"byPluginId\">; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"ea60a3329d0c562308bef80011e1e3169147eadb8c4fd98343fc71c8eb9cd827","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"b54c329dcf145df4c62be2fcb763a08b692edfa7bc4091d4a059454abd371eba","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters<typeof resolvePluginProviders>[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"4d40b7ef2d1cc95ccfc5eea75db17eef20b8356b1df02df5c157e68fc79bafe2","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"388729ca5432166998c770d2dc5f671c53ba6951c8b227d7785d838e99c340a1","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick<PluginMetadataSnapshot, \"owners\" | \"manifestRegistry\" | \"byPluginId\">; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"closureHash":"a5666fc15fd60e0168bd43305ecd02a27ef6415b7f2fa9853c7226589e1ededd","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"}
|
||||
{"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"}
|
||||
{"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"}
|
||||
{"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record<string, string> | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record<string, unknown>; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"}
|
||||
|
||||
@@ -190,6 +190,7 @@ export async function sendQaBusMessage(params: {
|
||||
accountId: string;
|
||||
to: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
threadId?: string;
|
||||
|
||||
@@ -316,7 +316,12 @@ describe("qa-channel plugin", () => {
|
||||
kind === "payload"
|
||||
? await adapter.send!.payload!({
|
||||
...context,
|
||||
payload: { text: context.text, mediaUrl: mediaPath, mediaUrls: [mediaPath] },
|
||||
payload: {
|
||||
text: context.text,
|
||||
mediaUrl: mediaPath,
|
||||
mediaUrls: [mediaPath],
|
||||
isError: true,
|
||||
},
|
||||
})
|
||||
: await adapter.send!.media!(context);
|
||||
expect(result.receipt.parts[0]).toMatchObject({
|
||||
@@ -324,6 +329,9 @@ describe("qa-channel plugin", () => {
|
||||
replyToId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
if (kind === "payload") {
|
||||
expect(harness.state.getSnapshot().messages.at(-1)?.isError).toBe(true);
|
||||
}
|
||||
};
|
||||
|
||||
await verifyChannelMessageAdapterCapabilityProofs({
|
||||
|
||||
@@ -68,6 +68,7 @@ async function sendQaChannelMessagePayload(ctx: QaChannelPayloadSendContext) {
|
||||
accountId: ctx.accountId,
|
||||
to: ctx.to,
|
||||
text,
|
||||
isError: ctx.payload.isError,
|
||||
threadId: ctx.threadId,
|
||||
replyToId: ctx.replyToId,
|
||||
};
|
||||
@@ -224,6 +225,7 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
outbound: {
|
||||
base: {
|
||||
deliveryMode: "direct",
|
||||
sendTextOnlyErrorPayloads: true,
|
||||
sendPayload: async (ctx) => {
|
||||
const result = await sendQaChannelMessagePayload(ctx);
|
||||
return { channel: QA_CHANNEL_ID, messageId: result.messageId };
|
||||
|
||||
@@ -207,7 +207,7 @@ function createQaReplyPreview(params: {
|
||||
currentText = "";
|
||||
};
|
||||
|
||||
const sendDurable = async (text: string) => {
|
||||
const sendDurable = async (text: string, isError?: boolean) => {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -217,6 +217,7 @@ function createQaReplyPreview(params: {
|
||||
accountId: params.account.accountId,
|
||||
to: params.target,
|
||||
text,
|
||||
isError,
|
||||
senderId: params.account.botUserId,
|
||||
senderName: params.account.botDisplayName,
|
||||
threadId: params.inbound.threadId,
|
||||
@@ -229,8 +230,15 @@ function createQaReplyPreview(params: {
|
||||
|
||||
return {
|
||||
clear,
|
||||
async deliver(text: string, kind: string) {
|
||||
async deliver(text: string, kind: string, isError?: boolean) {
|
||||
await pending;
|
||||
if (isError === true) {
|
||||
// Preview edits cannot add the typed failure marker. Replace any preview
|
||||
// with one durable marked message so QA Lab cannot accept it as success.
|
||||
await clear();
|
||||
await sendDurable(text, true);
|
||||
return;
|
||||
}
|
||||
// Core may close a streamed block with an identical final payload.
|
||||
// The block is already durable, so posting the final again duplicates the reply.
|
||||
if (
|
||||
@@ -419,7 +427,12 @@ export async function handleQaInbound(params: {
|
||||
deliver: async (payload, info) => {
|
||||
const reply =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as { text?: string; mediaUrl?: string; mediaUrls?: string[] })
|
||||
? (payload as {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
isError?: boolean;
|
||||
})
|
||||
: undefined;
|
||||
const text = reply?.text ?? "";
|
||||
const mediaUrls = Array.from(
|
||||
@@ -444,6 +457,7 @@ export async function handleQaInbound(params: {
|
||||
accountId: params.account.accountId,
|
||||
to: target,
|
||||
text,
|
||||
isError: reply?.isError,
|
||||
mediaUrls,
|
||||
mediaLocalRoots: getAgentScopedMediaLocalRoots(
|
||||
params.config as OpenClawConfig,
|
||||
@@ -457,7 +471,7 @@ export async function handleQaInbound(params: {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
await preview.deliver(text, info?.kind ?? "final");
|
||||
await preview.deliver(text, info?.kind ?? "final", reply?.isError);
|
||||
},
|
||||
onError: (error) => {
|
||||
void preview.clear().catch((clearError: unknown) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ type QaChannelTextSendParams = {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
threadId?: string | number | null;
|
||||
replyToId?: string | number | null;
|
||||
attachments?: QaBusAttachment[];
|
||||
@@ -39,6 +40,7 @@ export async function sendQaChannelText(params: QaChannelTextSendParams) {
|
||||
threadId: resolved.threadId,
|
||||
}),
|
||||
text: params.text,
|
||||
isError: params.isError,
|
||||
senderId: account.botUserId,
|
||||
senderName: account.botDisplayName,
|
||||
threadId: resolved.threadId,
|
||||
|
||||
@@ -140,6 +140,7 @@ export function createQaBusState() {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
timestamp?: number;
|
||||
threadId?: string;
|
||||
threadTitle?: string;
|
||||
@@ -174,6 +175,7 @@ export function createQaBusState() {
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
text: params.text,
|
||||
...(params.isError === true ? { isError: true } : {}),
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
threadId: params.threadId,
|
||||
threadTitle: params.threadTitle,
|
||||
@@ -246,6 +248,7 @@ export function createQaBusState() {
|
||||
senderId: input.senderId?.trim() || DEFAULT_BOT_ID,
|
||||
senderName: input.senderName?.trim() || DEFAULT_BOT_NAME,
|
||||
text: input.text,
|
||||
isError: input.isError,
|
||||
timestamp: input.timestamp,
|
||||
threadId: input.threadId ?? threadId,
|
||||
replyToId: input.replyToId,
|
||||
|
||||
@@ -1,51 +1,78 @@
|
||||
// Qa Lab integration tests cover the real QA Channel runtime contract.
|
||||
import { qaChannelPlugin, setQaChannelRuntime } from "@openclaw/qa-channel/api.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createTestRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startQaBusServer } from "./bus-server.js";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import { createQaRunnerRuntime } from "./harness-runtime.js";
|
||||
import { createQaChannelGatewayConfig } from "./qa-channel-transport.js";
|
||||
import { createQaChannelGatewayConfig, createQaChannelTransport } from "./qa-channel-transport.js";
|
||||
|
||||
async function startQaRuntimeIntegration(runtime = createQaRunnerRuntime()) {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "qa-channel", plugin: qaChannelPlugin, source: "test" }]),
|
||||
);
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
setQaChannelRuntime(runtime);
|
||||
const config = createQaChannelGatewayConfig({ baseUrl: bus.baseUrl });
|
||||
const account = qaChannelPlugin.config.resolveAccount(config, "default");
|
||||
const abort = new AbortController();
|
||||
const startAccount = qaChannelPlugin.gateway?.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("QA Channel gateway is unavailable");
|
||||
}
|
||||
const gatewayTask = startAccount({
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
cfg: config,
|
||||
runtime: {
|
||||
log: () => undefined,
|
||||
error: () => undefined,
|
||||
exit: () => undefined,
|
||||
},
|
||||
abortSignal: abort.signal,
|
||||
log: {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
debug: () => undefined,
|
||||
},
|
||||
getStatus: () => ({
|
||||
accountId: account.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
}),
|
||||
setStatus: () => undefined,
|
||||
});
|
||||
return {
|
||||
state,
|
||||
gatewayTask,
|
||||
async stop() {
|
||||
abort.abort();
|
||||
try {
|
||||
await gatewayTask;
|
||||
} finally {
|
||||
await bus.stop();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
describe("QA runner runtime integration", () => {
|
||||
it("dispatches a QA Channel inbound turn through the embedded runner", async () => {
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
const runtime = createQaRunnerRuntime();
|
||||
setQaChannelRuntime(runtime);
|
||||
const config = createQaChannelGatewayConfig({ baseUrl: bus.baseUrl });
|
||||
const account = qaChannelPlugin.config.resolveAccount(config, "default");
|
||||
const abort = new AbortController();
|
||||
const startAccount = qaChannelPlugin.gateway?.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("QA Channel gateway is unavailable");
|
||||
}
|
||||
const gatewayTask = startAccount({
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
cfg: config,
|
||||
runtime: {
|
||||
log: () => undefined,
|
||||
error: () => undefined,
|
||||
exit: () => undefined,
|
||||
},
|
||||
abortSignal: abort.signal,
|
||||
log: {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
debug: () => undefined,
|
||||
},
|
||||
getStatus: () => ({
|
||||
accountId: account.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
}),
|
||||
setStatus: () => undefined,
|
||||
});
|
||||
const harness = await startQaRuntimeIntegration();
|
||||
|
||||
try {
|
||||
state.addInboundMessage({
|
||||
harness.state.addInboundMessage({
|
||||
accountId: "default",
|
||||
conversation: { kind: "direct", id: "alice" },
|
||||
senderId: "alice",
|
||||
@@ -56,23 +83,78 @@ describe("QA runner runtime integration", () => {
|
||||
await Promise.race([
|
||||
vi.waitFor(
|
||||
() => {
|
||||
expect(state.getSnapshot().messages).toContainEqual(
|
||||
expect(harness.state.getSnapshot().messages).toContainEqual(
|
||||
expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }),
|
||||
);
|
||||
},
|
||||
{ interval: 25, timeout: 2_000 },
|
||||
),
|
||||
gatewayTask.then(() => {
|
||||
harness.gatewayTask.then(() => {
|
||||
throw new Error("QA Channel gateway stopped before delivering the turn");
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
abort.abort();
|
||||
try {
|
||||
await gatewayTask;
|
||||
} finally {
|
||||
await bus.stop();
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks and detects text-only errors across QA delivery paths", async () => {
|
||||
const failureText = "Text-only QA failure from the embedded runner.";
|
||||
const runtime = createQaRunnerRuntime();
|
||||
const dispatchFailure = async (
|
||||
params: Parameters<typeof runtime.channel.inbound.dispatch>[0],
|
||||
) => {
|
||||
if (!("deliver" in params.delivery) || typeof params.delivery.deliver !== "function") {
|
||||
throw new Error("QA failure fixture requires core-managed delivery");
|
||||
}
|
||||
await params.delivery.deliver({ text: failureText, isError: true }, { kind: "final" });
|
||||
return {
|
||||
admission: params.admission ?? { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: params.ctxPayload,
|
||||
routeSessionKey: params.route.sessionKey,
|
||||
dispatchResult: {
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
},
|
||||
};
|
||||
};
|
||||
runtime.channel.inbound.dispatch = dispatchFailure as typeof runtime.channel.inbound.dispatch;
|
||||
const harness = await startQaRuntimeIntegration(runtime);
|
||||
const transport = createQaChannelTransport(harness.state);
|
||||
let sent = false;
|
||||
|
||||
try {
|
||||
expect(qaChannelPlugin.outbound?.sendTextOnlyErrorPayloads).toBe(true);
|
||||
await Promise.race([
|
||||
expect(
|
||||
transport.waitForCondition(
|
||||
async () => {
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
harness.state.addInboundMessage({
|
||||
accountId: "default",
|
||||
conversation: { kind: "direct", id: "alice" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "trigger a text-only failure",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
2_000,
|
||||
25,
|
||||
),
|
||||
).rejects.toThrow(failureText),
|
||||
harness.gatewayTask.then(() => {
|
||||
throw new Error("QA Channel gateway stopped before delivering the failure");
|
||||
}),
|
||||
]);
|
||||
expect(harness.state.getSnapshot().messages).toContainEqual(
|
||||
expect.objectContaining({ direction: "outbound", text: failureText, isError: true }),
|
||||
);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,7 @@ describe("qa channel transport", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "other",
|
||||
to: "dm:alice",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: foreign account failure",
|
||||
});
|
||||
const expected = state.addOutboundMessage({
|
||||
@@ -239,6 +240,7 @@ describe("qa channel transport", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "other",
|
||||
to: "dm:alice",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: foreign account failure",
|
||||
});
|
||||
return "owned condition completed";
|
||||
@@ -281,6 +283,7 @@ describe("qa channel transport", () => {
|
||||
await transport.state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: synthetic failure for wait helper",
|
||||
});
|
||||
}
|
||||
@@ -298,6 +301,7 @@ describe("qa channel transport", () => {
|
||||
await transport.state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: stale failure should not leak",
|
||||
});
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ describe("waitForQaTransportOutboundSequence", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:alice",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: provider rejected this request",
|
||||
});
|
||||
|
||||
@@ -266,6 +267,7 @@ describe("waitForQaTransportOutboundSequence", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:alice",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: stale failure",
|
||||
});
|
||||
const sinceCursor = state.getSnapshot().cursor;
|
||||
@@ -273,6 +275,7 @@ describe("waitForQaTransportOutboundSequence", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "other",
|
||||
to: "dm:alice",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: foreign account failure",
|
||||
});
|
||||
const inbound = state.addInboundMessage({
|
||||
|
||||
@@ -228,7 +228,7 @@ export function findFailureOutboundMessage(
|
||||
(message) =>
|
||||
message.direction === "outbound" &&
|
||||
(!options?.accountId || message.accountId === options.accountId) &&
|
||||
Boolean(extractQaFailureReplyText(message.text)),
|
||||
Boolean(extractQaFailureReplyText(message)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ function assertNoFailureReplies(
|
||||
) {
|
||||
const failureMessage = findFailureOutboundMessage(state, options);
|
||||
if (failureMessage) {
|
||||
throw new Error(extractQaFailureReplyText(failureMessage.text) ?? failureMessage.text);
|
||||
throw new Error(extractQaFailureReplyText(failureMessage) ?? failureMessage.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,9 +564,7 @@ export async function waitForQaTransportOutboundSequence(params: {
|
||||
// Failures belong to the account, even when a different conversation has a matching final.
|
||||
for (const { kind, message } of ownedEvents) {
|
||||
const failureReply =
|
||||
kind === "deleted" || message.deleted
|
||||
? undefined
|
||||
: extractQaFailureReplyText(message.text);
|
||||
kind === "deleted" || message.deleted ? undefined : extractQaFailureReplyText(message);
|
||||
if (failureReply) {
|
||||
throw new Error(failureReply);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
// Qa Lab tests cover reply failure plugin behavior.
|
||||
// Qa Lab tests cover typed reply failure markers and independent leak detection.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractQaFailureReplyText, extractQaVisibleReplyLeakText } from "./reply-failure.js";
|
||||
|
||||
describe("extractQaFailureReplyText", () => {
|
||||
it("returns undefined for normal assistant replies", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText("Yes, precious. The build is green and a little cursed."),
|
||||
extractQaFailureReplyText({
|
||||
text: "Yes, precious. The build is green and a little cursed.",
|
||||
}),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("classifies the generic external fallback reply as a failure", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText(
|
||||
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.",
|
||||
),
|
||||
).toContain("Something went wrong while processing your request.");
|
||||
it("classifies marked failures without depending on copy wording", () => {
|
||||
const text = "Any future user-facing failure wording can go here.";
|
||||
expect(extractQaFailureReplyText({ text, isError: true })).toBe(text);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"⚠️ Agent couldn't generate a response. Please try again.",
|
||||
"⚠️ Agent couldn't generate a response. Note: some tool actions may have already been executed — please verify before retrying.",
|
||||
])("classifies the canonical incomplete-turn warning as a failure: %s", (reply) => {
|
||||
expect(extractQaFailureReplyText(reply)).toBe(reply);
|
||||
it("does not classify legacy failure-looking copy without the marker", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText({
|
||||
text: "⚠️ Something went wrong while processing your request.",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("classifies explicit provider auth guidance as a failure", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText(
|
||||
'⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.',
|
||||
),
|
||||
).toContain('No API key found for provider "openai".');
|
||||
});
|
||||
|
||||
it("classifies curated missing-key guidance as a failure", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText(
|
||||
"⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-luna` with the Codex OAuth profile, or set `OPENAI_API_KEY`, then try again.",
|
||||
),
|
||||
).toContain("Missing API key for OpenAI on the gateway.");
|
||||
});
|
||||
|
||||
it("classifies leaked codex harness coordination chatter as a failure", () => {
|
||||
expect(
|
||||
extractQaFailureReplyText("checking thread context; then post a tight progress reply here."),
|
||||
).toContain("checking thread context");
|
||||
it("classifies leaked harness coordination chatter independently", () => {
|
||||
const text = "checking thread context; then post a tight progress reply here.";
|
||||
expect(extractQaFailureReplyText({ text })).toContain("checking thread context");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
// Qa Lab plugin module implements reply failure behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const FAILURE_REPLY_PREFIXES = [
|
||||
"⚠️ something went wrong while processing your request.",
|
||||
"⚠️ agent couldn't generate a response",
|
||||
"⚠️ session history got out of sync.",
|
||||
"⚠️ session history was corrupted.",
|
||||
"⚠️ context overflow",
|
||||
"⚠️ message ordering conflict.",
|
||||
"⚠️ model login expired on the gateway",
|
||||
"⚠️ model login failed on the gateway",
|
||||
"⚠️ agent failed before reply:",
|
||||
"⚠️ ✉️ message failed",
|
||||
"⚠️ no api key found for provider ",
|
||||
"⚠️ missing api key for ",
|
||||
];
|
||||
|
||||
const VISIBLE_REPLY_LEAK_PATTERNS = [
|
||||
/\bchecking thread context\b/i,
|
||||
/\bthread context thin\b/i,
|
||||
@@ -41,13 +24,15 @@ export function extractQaVisibleReplyLeakText(text: string): string | undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractQaFailureReplyText(text: string): string | undefined {
|
||||
const trimmed = text.trim();
|
||||
export function extractQaFailureReplyText(message: {
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
}): string | undefined {
|
||||
const trimmed = message.text.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (FAILURE_REPLY_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
if (message.isError === true) {
|
||||
return trimmed;
|
||||
}
|
||||
const visibleReplyLeak = extractQaVisibleReplyLeakText(trimmed);
|
||||
|
||||
@@ -11,18 +11,22 @@ const classifiedFailureReplies = [
|
||||
{
|
||||
failureName: "provider failure",
|
||||
failureText: '⚠️ No API key found for provider "openai".',
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
failureName: "delivery failure",
|
||||
failureText: "⚠️ ✉️ Message failed",
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
failureName: "missing tool failure",
|
||||
failureText: "Read: AGENT.md\nEvidence snippet: Tool read not found\nStatus: blocked",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
failureName: "internal coordination leak",
|
||||
failureText: "checking thread context; then post a tight progress reply here.",
|
||||
isError: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -113,15 +117,16 @@ describe("character scenario transcript safety", () => {
|
||||
|
||||
it.each(
|
||||
characterScenarioIds.flatMap((scenarioId) =>
|
||||
classifiedFailureReplies.map(({ failureName, failureText }) => ({
|
||||
classifiedFailureReplies.map(({ failureName, failureText, isError }) => ({
|
||||
scenarioId,
|
||||
failureName,
|
||||
failureText,
|
||||
isError,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"rejects a $failureName after an actual reply in $scenarioId",
|
||||
async ({ scenarioId, failureText }) => {
|
||||
async ({ scenarioId, failureText, isError }) => {
|
||||
const state = createQaBusState();
|
||||
const firstReply = "The build is green, and I am here.";
|
||||
let waitCount = 0;
|
||||
@@ -134,6 +139,7 @@ describe("character scenario transcript safety", () => {
|
||||
accountId: "qa-channel",
|
||||
to: "dm:alice",
|
||||
text: waitCount++ === 0 ? firstReply : failureText,
|
||||
...(waitCount > 1 && isError ? { isError: true } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("qa suite transport helpers", () => {
|
||||
const state = createQaBusState();
|
||||
state.addOutboundMessage({
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.",
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
@@ -36,6 +37,7 @@ describe("qa suite transport helpers", () => {
|
||||
|
||||
state.addOutboundMessage({
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.',
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
@@ -54,6 +56,7 @@ describe("qa suite transport helpers", () => {
|
||||
|
||||
state.addOutboundMessage({
|
||||
to: "channel:qa-room",
|
||||
isError: true,
|
||||
text: "⚠️ ✉️ Message failed",
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
@@ -150,6 +153,7 @@ describe("qa suite transport helpers", () => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "other",
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: "⚠️ agent failed before reply: foreign account failure",
|
||||
});
|
||||
const expected = state.addOutboundMessage({
|
||||
@@ -185,6 +189,7 @@ describe("qa suite transport helpers", () => {
|
||||
|
||||
state.addOutboundMessage({
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.',
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
@@ -232,6 +237,7 @@ describe("qa suite transport helpers", () => {
|
||||
|
||||
state.addOutboundMessage({
|
||||
to: "dm:qa-operator",
|
||||
isError: true,
|
||||
text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.',
|
||||
senderId: "openclaw",
|
||||
senderName: "OpenClaw QA",
|
||||
|
||||
@@ -28,7 +28,7 @@ async function waitForOutboundMessage(
|
||||
return await waitForQaTransportCondition(() => {
|
||||
const failureMessage = findFailureOutboundMessage(state, options);
|
||||
if (failureMessage) {
|
||||
throw new Error(extractQaFailureReplyText(failureMessage.text) ?? failureMessage.text);
|
||||
throw new Error(extractQaFailureReplyText(failureMessage) ?? failureMessage.text);
|
||||
}
|
||||
const match = state
|
||||
.getSnapshot()
|
||||
@@ -43,7 +43,7 @@ async function waitForOutboundMessage(
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const failureReply = extractQaFailureReplyText(match.text);
|
||||
const failureReply = extractQaFailureReplyText(match);
|
||||
if (failureReply) {
|
||||
throw new Error(failureReply);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
* Verifies actionable recovery hints, transient-copy suppression, provider
|
||||
* naming, and diagnostic cause handling.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const LOGIN_HINT_SENTINEL = "<<login-hint-for-provider>>";
|
||||
|
||||
vi.mock("../provider-auth-recovery-hint.js", () => ({
|
||||
buildProviderAuthRecoveryHint: (params: { provider: string }) =>
|
||||
`${LOGIN_HINT_SENTINEL}:${params.provider}`,
|
||||
}));
|
||||
|
||||
import type { FailoverReason } from "../failover/signal.js";
|
||||
import { formatAuthProfileFailureMessage } from "./failure-copy.js";
|
||||
import { renderAuthProfileFailoverCopy } from "../failover/user-copy.js";
|
||||
|
||||
const formatAuthProfileFailureMessage = (
|
||||
params: Parameters<typeof renderAuthProfileFailoverCopy>[0],
|
||||
) =>
|
||||
renderAuthProfileFailoverCopy({
|
||||
...params,
|
||||
recoveryHint: `${LOGIN_HINT_SENTINEL}:${params.provider}`,
|
||||
});
|
||||
|
||||
const PROVIDER = "openai-codex";
|
||||
|
||||
@@ -32,7 +35,7 @@ const REASONS_WITHOUT_RECOVERY: readonly FailoverReason[] = [
|
||||
"format",
|
||||
];
|
||||
|
||||
describe("formatAuthProfileFailureMessage", () => {
|
||||
describe("renderAuthProfileFailoverCopy", () => {
|
||||
describe("recovery-hint dispatch", () => {
|
||||
it("includes the login command for reasons the user can act on", () => {
|
||||
for (const reason of REASONS_WITH_RECOVERY) {
|
||||
@@ -83,14 +86,14 @@ describe("formatAuthProfileFailureMessage", () => {
|
||||
|
||||
describe("cause handling", () => {
|
||||
it("returns the cause text verbatim when the reason has no actionable copy", () => {
|
||||
const cause = new Error("upstream provider returned 502");
|
||||
const causeText = "upstream provider returned 502";
|
||||
const message = formatAuthProfileFailureMessage({
|
||||
reason: "unknown",
|
||||
provider: PROVIDER,
|
||||
allInCooldown: false,
|
||||
cause,
|
||||
causeText,
|
||||
});
|
||||
expect(message).toBe(cause.message);
|
||||
expect(message).toBe(causeText);
|
||||
});
|
||||
|
||||
it("appends a diagnostic suffix when the cause adds detail beyond the description", () => {
|
||||
@@ -98,7 +101,7 @@ describe("formatAuthProfileFailureMessage", () => {
|
||||
reason: "auth",
|
||||
provider: PROVIDER,
|
||||
allInCooldown: false,
|
||||
cause: new Error("invalid_grant"),
|
||||
causeText: "invalid_grant",
|
||||
});
|
||||
expect(message).toContain("(invalid_grant)");
|
||||
});
|
||||
@@ -118,7 +121,7 @@ describe("formatAuthProfileFailureMessage", () => {
|
||||
reason: "auth",
|
||||
provider: PROVIDER,
|
||||
allInCooldown: false,
|
||||
cause: new Error(description),
|
||||
causeText: description,
|
||||
});
|
||||
expect(withDuplicateCause).toBe(withoutCause);
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* User-facing auth-profile failure copy.
|
||||
* Maps failover reasons into provider-specific recovery guidance while keeping
|
||||
* raw error detail as a short diagnostic suffix.
|
||||
*/
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { FailoverReason } from "../failover/signal.js";
|
||||
import { buildProviderAuthRecoveryHint } from "../provider-auth-recovery-hint.js";
|
||||
|
||||
type AuthProfileFailureCopyParams = {
|
||||
reason: FailoverReason;
|
||||
provider: string;
|
||||
/**
|
||||
* True when the failure was reached because every configured profile is in
|
||||
* cooldown / blocked. False when an attempt to use a specific profile threw
|
||||
* (e.g. credential lookup failed). The two paths produce different copy
|
||||
* because only the cooldown case implies "wait or rotate"; the other case
|
||||
* implies "the credential itself is broken".
|
||||
*/
|
||||
allInCooldown: boolean;
|
||||
/**
|
||||
* Underlying error that triggered the failover, if any. Used to append a
|
||||
* short diagnostic suffix and to fall back to the original message when no
|
||||
* structured recovery copy applies.
|
||||
*/
|
||||
cause?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function describeReason(
|
||||
reason: FailoverReason,
|
||||
provider: string,
|
||||
allInCooldown: boolean,
|
||||
): string | null {
|
||||
if (allInCooldown) {
|
||||
switch (reason) {
|
||||
case "auth":
|
||||
case "session_expired":
|
||||
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
|
||||
case "auth_permanent":
|
||||
return `${provider} isn't accepting your saved login anymore.`;
|
||||
case "billing":
|
||||
return `${provider} rejected the request — looks like a billing issue on the account.`;
|
||||
case "rate_limit":
|
||||
return `${provider} is asking us to slow down. Please wait a moment before trying again.`;
|
||||
case "overloaded":
|
||||
return `${provider} is overloaded right now. Please wait a moment before trying again.`;
|
||||
case "timeout":
|
||||
return `${provider} hasn't been responding. Please wait a moment before trying again.`;
|
||||
case "model_not_found":
|
||||
return `${provider} can't find the model you're using right now.`;
|
||||
case "server_error":
|
||||
return `${provider} is having issues right now. Please wait a moment before trying again.`;
|
||||
default:
|
||||
return `Couldn't reach ${provider} with any of your saved logins right now.`;
|
||||
}
|
||||
}
|
||||
switch (reason) {
|
||||
case "auth":
|
||||
case "session_expired":
|
||||
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
|
||||
case "auth_permanent":
|
||||
return `${provider} isn't accepting your saved login.`;
|
||||
case "billing":
|
||||
return `${provider} rejected the request — looks like a billing issue on the account.`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldIncludeRecoveryHint(reason: FailoverReason): boolean {
|
||||
switch (reason) {
|
||||
case "auth":
|
||||
case "auth_permanent":
|
||||
case "session_expired":
|
||||
case "billing":
|
||||
return true;
|
||||
case "rate_limit":
|
||||
case "overloaded":
|
||||
case "timeout":
|
||||
case "tls_certificate":
|
||||
case "server_error":
|
||||
case "model_not_found":
|
||||
case "format":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticSuffix(cause: unknown, primary: string): string | null {
|
||||
if (cause === undefined || cause === null) {
|
||||
return null;
|
||||
}
|
||||
const text = formatErrorMessage(cause).trim();
|
||||
if (!text || primary.includes(text)) {
|
||||
return null;
|
||||
}
|
||||
return ` (${text})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for user-facing copy when an auth-profile rotation
|
||||
* fails. Composes a reason-specific sentence with an actionable next-step
|
||||
* derived from the provider's plugin manifest (`buildProviderAuthRecoveryHint`).
|
||||
*
|
||||
* Falls back to the underlying error's text when the reason maps to nothing
|
||||
* actionable, so we never produce worse copy than the raw error.
|
||||
*/
|
||||
export function formatAuthProfileFailureMessage(params: AuthProfileFailureCopyParams): string {
|
||||
const description = describeReason(params.reason, params.provider, params.allInCooldown);
|
||||
if (!description) {
|
||||
const causeText = params.cause ? formatErrorMessage(params.cause).trim() : "";
|
||||
if (causeText) {
|
||||
return causeText;
|
||||
}
|
||||
return `Couldn't reach ${params.provider} with any of your saved logins right now.`;
|
||||
}
|
||||
const hint = shouldIncludeRecoveryHint(params.reason)
|
||||
? buildProviderAuthRecoveryHint({
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
: null;
|
||||
const suffix = diagnosticSuffix(params.cause, description);
|
||||
const parts = [description];
|
||||
if (hint) {
|
||||
parts.push(hint);
|
||||
}
|
||||
const message = parts.join(" ");
|
||||
return suffix ? `${message}${suffix}` : message;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
buildExecApprovalContinuationFallbackPrompt,
|
||||
buildExecApprovalContinuationPrompt,
|
||||
} from "./bash-tools.exec-approval-output.js";
|
||||
import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js";
|
||||
import {
|
||||
formatExecDeniedUserMessage,
|
||||
isExecDeniedResultText,
|
||||
@@ -169,7 +169,7 @@ function formatDirectExecApprovalFollowupText(
|
||||
if (parsed.kind === "finished") {
|
||||
const metadata = normalizeLowercaseStringOrEmpty(parsed.metadata);
|
||||
const body = redactToolPayloadText(
|
||||
sanitizeUserFacingText(parsed.body, {
|
||||
renderUserFacingText(parsed.body, {
|
||||
errorContext: !metadata.includes("code 0"),
|
||||
}),
|
||||
).trim();
|
||||
@@ -188,13 +188,13 @@ function formatDirectExecApprovalFollowupText(
|
||||
|
||||
if (parsed.kind === "completed") {
|
||||
const body = redactToolPayloadText(
|
||||
sanitizeUserFacingText(parsed.body, { errorContext: true }),
|
||||
renderUserFacingText(parsed.body, { errorContext: true }),
|
||||
).trim();
|
||||
return body || "Background command finished.";
|
||||
}
|
||||
|
||||
return (
|
||||
redactToolPayloadText(sanitizeUserFacingText(parsed.raw, { errorContext: true })).trim() || null
|
||||
redactToolPayloadText(renderUserFacingText(parsed.raw, { errorContext: true })).trim() || null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isRawApiErrorPayload,
|
||||
} from "./embedded-agent-helpers.js";
|
||||
import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js";
|
||||
import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js";
|
||||
|
||||
describe("formatAssistantErrorText", () => {
|
||||
@@ -71,10 +72,10 @@ describe("formatAssistantErrorText", () => {
|
||||
expected: "The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
},
|
||||
{
|
||||
title: "preserves overload wording for Z.AI rate-limit errors",
|
||||
title: "uses classified rate-limit copy for Z.AI rate-limit errors",
|
||||
errorText:
|
||||
'429 status code (exceeded limit)\n{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}',
|
||||
expected: "The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
expected: "⚠️ API rate limit reached. Please try again later.",
|
||||
},
|
||||
{
|
||||
title: "rewrites generic provider internal errors without support request ids",
|
||||
@@ -757,7 +758,7 @@ describe("formatBillingErrorMessage — authMode neutral copy (#80877)", () => {
|
||||
|
||||
describe("sanitizeUserFacingText — streaming JSON parse error (#59076)", () => {
|
||||
it("rewrites transport-classified malformed streaming fragments in error context", () => {
|
||||
const result = sanitizeUserFacingText(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, {
|
||||
const result = renderUserFacingText(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, {
|
||||
errorContext: true,
|
||||
});
|
||||
expect(result).toBe("LLM streaming response contained a malformed fragment. Please try again.");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./embedded-agent-helpers.js";
|
||||
import { stripThoughtSignatures } from "./embedded-agent-helpers/bootstrap.js";
|
||||
import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js";
|
||||
import { formatAgentInternalEventsForPrompt } from "./internal-events.js";
|
||||
import {
|
||||
INTERNAL_RUNTIME_CONTEXT_BEGIN,
|
||||
@@ -57,18 +58,18 @@ describe("sanitizeUserFacingText", () => {
|
||||
);
|
||||
|
||||
it("sanitizes role ordering errors", () => {
|
||||
const result = sanitizeUserFacingText("400 Incorrect role information", { errorContext: true });
|
||||
const result = renderUserFacingText("400 Incorrect role information", { errorContext: true });
|
||||
expect(result).toContain("Message ordering conflict");
|
||||
});
|
||||
|
||||
it("sanitizes HTTP status errors with error hints", () => {
|
||||
expect(sanitizeUserFacingText("500 Internal Server Error", { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText("500 Internal Server Error", { errorContext: true })).toBe(
|
||||
"HTTP 500: Internal Server Error",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a provider-completed finish_reason error", () => {
|
||||
expect(sanitizeUserFacingText("Provider finish_reason: error", { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText("Provider finish_reason: error", { errorContext: true })).toBe(
|
||||
"Provider finish_reason: error",
|
||||
);
|
||||
});
|
||||
@@ -77,7 +78,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
"Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.",
|
||||
"Request size exceeds model context window",
|
||||
])("sanitizes direct context-overflow error: %s", (text) => {
|
||||
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
|
||||
expect(renderUserFacingText(text, { errorContext: true })).toContain(
|
||||
"Context overflow: prompt too large for the model.",
|
||||
);
|
||||
});
|
||||
@@ -85,7 +86,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => {
|
||||
const text =
|
||||
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}';
|
||||
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
|
||||
expect(renderUserFacingText(text, { errorContext: true })).toContain(
|
||||
"Context overflow: prompt too large for the model.",
|
||||
);
|
||||
});
|
||||
@@ -112,12 +113,12 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("rewrites billing error-shaped text with errorContext", () => {
|
||||
const text = "billing: please upgrade your plan";
|
||||
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain("billing error");
|
||||
expect(renderUserFacingText(text, { errorContext: true })).toContain("billing error");
|
||||
});
|
||||
|
||||
it("rewrites exec denied payloads with errorContext", () => {
|
||||
expect(
|
||||
sanitizeUserFacingText("Exec denied (gateway id=req-1, approval-timeout): bash -lc ls", {
|
||||
renderUserFacingText("Exec denied (gateway id=req-1, approval-timeout): bash -lc ls", {
|
||||
errorContext: true,
|
||||
}),
|
||||
).toBe("Command did not run: approval timed out.");
|
||||
@@ -125,7 +126,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("sanitizes raw API error payloads", () => {
|
||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||
expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText(raw, { errorContext: true })).toBe(
|
||||
"LLM error server_error: Something exploded",
|
||||
);
|
||||
});
|
||||
@@ -138,7 +139,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
it("sanitizes Codex error-prefixed API payloads", () => {
|
||||
const raw =
|
||||
'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}';
|
||||
expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText(raw, { errorContext: true })).toBe(
|
||||
"LLM error server_error: Something exploded",
|
||||
);
|
||||
});
|
||||
@@ -146,7 +147,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
it("sanitizes Codex error-prefixed API payloads without explicit errorContext", () => {
|
||||
const raw =
|
||||
'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}';
|
||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||
expect(renderUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||
});
|
||||
|
||||
it("keeps regular JSON examples intact without explicit errorContext", () => {
|
||||
@@ -157,7 +158,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
it("preserves specialized context overflow guidance for raw API payloads", () => {
|
||||
const raw =
|
||||
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}';
|
||||
expect(sanitizeUserFacingText(raw, { errorContext: true })).toContain(
|
||||
expect(renderUserFacingText(raw, { errorContext: true })).toContain(
|
||||
"Context overflow: prompt too large for the model.",
|
||||
);
|
||||
});
|
||||
@@ -165,20 +166,20 @@ describe("sanitizeUserFacingText", () => {
|
||||
it("preserves specialized context overflow guidance for Codex-prefixed API payloads", () => {
|
||||
const raw =
|
||||
'Codex error: {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}';
|
||||
expect(sanitizeUserFacingText(raw, { errorContext: true })).toContain(
|
||||
expect(renderUserFacingText(raw, { errorContext: true })).toContain(
|
||||
"Context overflow: prompt too large for the model.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a friendly message for rate limit errors in Error: prefixed payloads", () => {
|
||||
expect(sanitizeUserFacingText("Error: 429 Rate limit exceeded", { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText("Error: 429 Rate limit exceeded", { errorContext: true })).toBe(
|
||||
"⚠️ API rate limit reached. Please try again later.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves rate limit reset details that use resets wording", () => {
|
||||
expect(
|
||||
sanitizeUserFacingText("Error: Rate limit reached, resets 6pm (UTC)", {
|
||||
renderUserFacingText("Error: Rate limit reached, resets 6pm (UTC)", {
|
||||
errorContext: true,
|
||||
}),
|
||||
).toBe("⚠️ Rate limit reached, resets 6pm (UTC)");
|
||||
@@ -186,7 +187,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("returns a model-switch hint for OpenAI model capacity errors", () => {
|
||||
expect(
|
||||
sanitizeUserFacingText(
|
||||
renderUserFacingText(
|
||||
"OpenAI error: Selected model is at capacity. Please try a different model.",
|
||||
{
|
||||
errorContext: true,
|
||||
@@ -197,7 +198,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => {
|
||||
expect(
|
||||
sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", {
|
||||
renderUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", {
|
||||
errorContext: true,
|
||||
}),
|
||||
).toBe("LLM request failed: connection refused by the provider endpoint.");
|
||||
@@ -206,7 +207,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
it.each(["disk full", "ENOSPC: no space left on device"])(
|
||||
"rewrites disk-space failures with errorContext: %s",
|
||||
(input) => {
|
||||
expect(sanitizeUserFacingText(input, { errorContext: true })).toBe(
|
||||
expect(renderUserFacingText(input, { errorContext: true })).toBe(
|
||||
"OpenClaw could not write local session data because the disk is full. Free some disk space and try again.",
|
||||
);
|
||||
},
|
||||
@@ -214,7 +215,7 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("sanitizes invalid streaming event order errors", () => {
|
||||
expect(
|
||||
sanitizeUserFacingText(
|
||||
renderUserFacingText(
|
||||
'Unexpected event order, got message_start before receiving "message_stop"',
|
||||
{ errorContext: true },
|
||||
),
|
||||
|
||||
@@ -31,10 +31,9 @@ export type { ProviderRuntimeFailureKind } from "./embedded-agent-helpers/provid
|
||||
export {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
formatBillingErrorMessage,
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
getApiErrorPayloadFingerprint,
|
||||
isRawApiErrorPayload,
|
||||
} from "./embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
} from "./failover/user-copy.js";
|
||||
export {
|
||||
formatRawAssistantErrorForUi,
|
||||
parseApiErrorInfo,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseApiErrorInfo,
|
||||
} from "../../shared/assistant-error-format.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isBilling429MessageForProvider,
|
||||
isBillingErrorMessage,
|
||||
isContextOverflowError,
|
||||
@@ -18,26 +19,23 @@ import {
|
||||
isReasoningConstraintErrorMessage,
|
||||
isTimeoutErrorMessage,
|
||||
} from "../failover/classify.js";
|
||||
import {
|
||||
AUTH_INVALID_TOKEN_USER_TEXT,
|
||||
formatBillingErrorMessage,
|
||||
formatDiskSpaceErrorCopy,
|
||||
formatTransportErrorCopy,
|
||||
isInvalidStreamingEventOrderError,
|
||||
isLikelyHttpErrorText,
|
||||
isRawApiErrorPayload,
|
||||
isStreamingJsonParseError,
|
||||
renderRateLimitOrOverloadedCopy,
|
||||
} from "../failover/user-copy.js";
|
||||
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox/runtime-status.js";
|
||||
import { buildAssistantFailoverSignal } from "./assistant-message-failures.js";
|
||||
import { classifyProviderRuntimeFailureKind } from "./provider-runtime-failure.js";
|
||||
import {
|
||||
formatBillingErrorMessage,
|
||||
formatDiskSpaceErrorCopy,
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
formatTransportErrorCopy,
|
||||
isInvalidStreamingEventOrderError,
|
||||
isLikelyHttpErrorText as isLikelyHttpErrorTextFromSanitizer,
|
||||
isRawApiErrorPayload,
|
||||
isStreamingJsonParseError,
|
||||
} from "./sanitize-user-facing-text.js";
|
||||
const log = createSubsystemLogger("errors");
|
||||
const sandboxToolPolicyAuditMessages = new WeakSet<AssistantMessage>();
|
||||
export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed.";
|
||||
export const AUTH_INVALID_TOKEN_USER_TEXT =
|
||||
"Authentication failed (provider returned HTTP 401). " +
|
||||
"Your provider token may have expired — try the request again in a moment. " +
|
||||
"If the failure persists, re-authenticate this provider.";
|
||||
const PROVIDER_SCHEMA_REJECTION_USER_TEXT =
|
||||
"LLM request failed: provider rejected the request schema or tool payload.";
|
||||
const MODEL_NOT_FOUND_USER_TEXT =
|
||||
@@ -217,7 +215,11 @@ export function formatAssistantErrorText(
|
||||
return formatBillingErrorMessage(opts?.provider, opts?.model ?? msg.model, opts?.authMode);
|
||||
}
|
||||
|
||||
const transientCopy = formatRateLimitOrOverloadedErrorCopy(raw);
|
||||
const failoverReason = classifyFailoverReason(raw, { provider: opts?.provider });
|
||||
const transientCopy =
|
||||
failoverReason === "rate_limit" || failoverReason === "overloaded"
|
||||
? renderRateLimitOrOverloadedCopy({ reason: failoverReason, raw })
|
||||
: undefined;
|
||||
if (transientCopy) {
|
||||
return transientCopy;
|
||||
}
|
||||
@@ -249,7 +251,7 @@ export function formatAssistantErrorText(
|
||||
return PROVIDER_SCHEMA_REJECTION_USER_TEXT;
|
||||
}
|
||||
|
||||
if (isLikelyHttpErrorTextFromSanitizer(raw) || isRawApiErrorPayload(raw)) {
|
||||
if (isRawApiErrorPayload(raw) || isLikelyHttpErrorText(raw)) {
|
||||
return formatRawAssistantErrorForUi(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { stableStringify } from "@openclaw/normalization-core";
|
||||
/**
|
||||
* Converts raw provider/transport errors into concise user-facing copy.
|
||||
*/
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "../../../packages/normalization-core/src/string-coerce.js";
|
||||
/** Strips internal scaffolding from text before user-facing delivery. */
|
||||
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
|
||||
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
|
||||
import {
|
||||
extractLeadingHttpStatus,
|
||||
formatRawAssistantErrorForUi,
|
||||
isCloudflareOrHtmlErrorPage,
|
||||
isGenericProviderInternalError,
|
||||
MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE,
|
||||
parseApiErrorInfo,
|
||||
parseApiErrorPayload,
|
||||
} from "../../shared/assistant-error-format.js";
|
||||
import { coerceChatContentText } from "../../shared/chat-content.js";
|
||||
import {
|
||||
stripAssistantInternalTraceLines,
|
||||
@@ -26,353 +10,13 @@ import {
|
||||
} from "../../shared/text/assistant-visible-text.js";
|
||||
import { findCodeRegions } from "../../shared/text/code-regions.js";
|
||||
import { stripFinalTags } from "../../shared/text/final-tags.js";
|
||||
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
|
||||
import {
|
||||
isBillingErrorMessage,
|
||||
isOverloadedErrorMessage,
|
||||
isProviderCompletedErrorFinishReasonMessage,
|
||||
isRateLimitErrorMessage,
|
||||
isTimeoutErrorMessage,
|
||||
} from "../failover/classify.js";
|
||||
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
|
||||
|
||||
/** Format the billing failure copy with optional provider/model context.
|
||||
*
|
||||
* When `authMode` is `"oauth"` or `"token"` (i.e. Anthropic Max or a static
|
||||
* bearer-token subscription) the user has no API key to top up, so we emit
|
||||
* neutral copy that directs them to check their account instead (#80877).
|
||||
*/
|
||||
export function formatBillingErrorMessage(
|
||||
provider?: string,
|
||||
model?: string,
|
||||
authMode?: string,
|
||||
): string {
|
||||
const providerName = provider?.trim();
|
||||
const modelName = model?.trim();
|
||||
const providerLabel =
|
||||
providerName && modelName ? `${providerName} (${modelName})` : providerName || undefined;
|
||||
|
||||
// OAuth and static-token credentials do not have an API key to top up.
|
||||
const isSubscriptionAuth = authMode === "oauth" || authMode === "token";
|
||||
if (isSubscriptionAuth) {
|
||||
if (providerLabel) {
|
||||
return `⚠️ ${providerLabel} returned a billing error — check your account for subscription or usage limits, then try again.`;
|
||||
}
|
||||
return "⚠️ API provider returned a billing error — check your account for subscription or usage limits, then try again.";
|
||||
}
|
||||
|
||||
if (providerLabel) {
|
||||
return `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.`;
|
||||
}
|
||||
return "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key.";
|
||||
}
|
||||
|
||||
export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage();
|
||||
|
||||
const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later.";
|
||||
const MODEL_CAPACITY_ERROR_USER_MESSAGE =
|
||||
"⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
|
||||
const OVERLOADED_ERROR_USER_MESSAGE =
|
||||
"The AI service is temporarily overloaded. Please try again in a moment.";
|
||||
const TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE = /^[ \t]*\[tool calls omitted\][ \t]*$/i;
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
|
||||
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
|
||||
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
|
||||
const HTTP_ERROR_HINTS = [
|
||||
"error",
|
||||
"bad request",
|
||||
"not found",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"internal server",
|
||||
"service unavailable",
|
||||
"gateway",
|
||||
"rate limit",
|
||||
"overloaded",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"invalid",
|
||||
"too many requests",
|
||||
"permission",
|
||||
];
|
||||
const RATE_LIMIT_SPECIFIC_HINT_RE =
|
||||
/\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i;
|
||||
const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i;
|
||||
const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384;
|
||||
const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i;
|
||||
|
||||
function extractProviderRateLimitMessage(raw: string): string | undefined {
|
||||
const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim();
|
||||
const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix);
|
||||
const candidate =
|
||||
info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix);
|
||||
|
||||
if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isCloudflareOrHtmlErrorPage(withoutPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = candidate.trim();
|
||||
if (
|
||||
trimmed.length > 300 ||
|
||||
trimmed.startsWith("{") ||
|
||||
/^(?:<!doctype\s+html\b|<html\b)/i.test(trimmed)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `⚠️ ${trimmed}`;
|
||||
}
|
||||
|
||||
export function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined {
|
||||
if (MODEL_CAPACITY_ERROR_RE.test(raw)) {
|
||||
return MODEL_CAPACITY_ERROR_USER_MESSAGE;
|
||||
}
|
||||
const isRateLimit = isRateLimitErrorMessage(raw);
|
||||
if (isRateLimit) {
|
||||
const providerMessage = extractProviderRateLimitMessage(raw);
|
||||
if (providerMessage) {
|
||||
return providerMessage;
|
||||
}
|
||||
}
|
||||
// Retry classification still owns 429 backoff; user copy can preserve the provider's
|
||||
// overload wording when there is no more actionable retry/reset detail.
|
||||
if (isOverloadedErrorMessage(raw)) {
|
||||
return OVERLOADED_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (isRateLimit) {
|
||||
return RATE_LIMIT_ERROR_USER_MESSAGE;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTransportErrorCopy(raw: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isCloudflareOrHtmlErrorPage(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
|
||||
if (
|
||||
/\beconnrefused\b/i.test(raw) ||
|
||||
lower.includes("connection refused") ||
|
||||
lower.includes("actively refused")
|
||||
) {
|
||||
return "LLM request failed: connection refused by the provider endpoint.";
|
||||
}
|
||||
|
||||
if (
|
||||
/\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) ||
|
||||
lower.includes("socket hang up") ||
|
||||
lower.includes("connection reset") ||
|
||||
lower.includes("connection aborted")
|
||||
) {
|
||||
return "LLM request failed: network connection was interrupted.";
|
||||
}
|
||||
|
||||
if (
|
||||
/\benotfound\b|\beai_again\b/i.test(raw) ||
|
||||
lower.includes("getaddrinfo") ||
|
||||
lower.includes("no such host") ||
|
||||
lower.includes("dns")
|
||||
) {
|
||||
return "LLM request failed: DNS lookup for the provider endpoint failed.";
|
||||
}
|
||||
|
||||
if (
|
||||
/\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) ||
|
||||
lower.includes("network is unreachable") ||
|
||||
lower.includes("host is unreachable")
|
||||
) {
|
||||
return "LLM request failed: the provider endpoint is unreachable from this host.";
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("fetch failed") ||
|
||||
lower.includes("connection error") ||
|
||||
lower.includes("network request failed")
|
||||
) {
|
||||
return "LLM request failed: network connection error.";
|
||||
}
|
||||
|
||||
if (raw.includes("网络错误") || raw.includes("网络异常") || raw.includes("连接错误")) {
|
||||
return "LLM request failed: provider reported a network error.";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatDiskSpaceErrorCopy(raw: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (
|
||||
/\benospc\b/i.test(raw) ||
|
||||
lower.includes("no space left on device") ||
|
||||
lower.includes("disk full")
|
||||
) {
|
||||
return (
|
||||
"OpenClaw could not write local session data because the disk is full. " +
|
||||
"Free some disk space and try again."
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isReasoningConstraintErrorMessage(raw: string): boolean {
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
return (
|
||||
lower.includes("reasoning is mandatory") ||
|
||||
lower.includes("reasoning is required") ||
|
||||
lower.includes("requires reasoning") ||
|
||||
(lower.includes("reasoning") && lower.includes("cannot be disabled"))
|
||||
);
|
||||
}
|
||||
|
||||
export function isInvalidStreamingEventOrderError(raw: string): boolean {
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
return (
|
||||
lower.includes("unexpected event order") &&
|
||||
lower.includes("message_start") &&
|
||||
lower.includes("message_stop")
|
||||
);
|
||||
}
|
||||
|
||||
export function isStreamingJsonParseError(raw: string): boolean {
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRateLimitTpmHint(raw: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
return /\btpm\b/i.test(lower) || lower.includes("tokens per minute");
|
||||
}
|
||||
|
||||
function looksLikeGenericContextOverflowError(raw: string): boolean {
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
const hasRequestSizeExceeds = lower.includes("request size exceeds");
|
||||
const hasContextWindow =
|
||||
lower.includes("context window") ||
|
||||
lower.includes("context length") ||
|
||||
lower.includes("maximum context length");
|
||||
return (
|
||||
lower.includes("request_too_large") ||
|
||||
(lower.includes("invalid_argument") && lower.includes("maximum number of tokens")) ||
|
||||
lower.includes("request exceeds the maximum size") ||
|
||||
lower.includes("context length exceeded") ||
|
||||
lower.includes("maximum context length") ||
|
||||
lower.includes("prompt is too long") ||
|
||||
lower.includes("prompt too long") ||
|
||||
lower.includes("exceeds model context window") ||
|
||||
lower.includes("model token limit") ||
|
||||
(lower.includes("input exceeds") && lower.includes("maximum number of tokens")) ||
|
||||
(hasRequestSizeExceeds && hasContextWindow) ||
|
||||
lower.includes("context overflow:") ||
|
||||
lower.includes("exceed context limit") ||
|
||||
lower.includes("exceeds the model's maximum context") ||
|
||||
(lower.includes("max_tokens") && lower.includes("exceed") && lower.includes("context")) ||
|
||||
(lower.includes("input length") && lower.includes("exceed") && lower.includes("context")) ||
|
||||
(lower.includes("413") && lower.includes("too large")) ||
|
||||
lower.includes("context_window_exceeded") ||
|
||||
raw.includes("上下文过长") ||
|
||||
raw.includes("上下文超出") ||
|
||||
raw.includes("上下文长度超") ||
|
||||
raw.includes("超出最大上下文") ||
|
||||
raw.includes("请压缩上下文")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRewriteContextOverflowText(raw: string): boolean {
|
||||
if (hasRateLimitTpmHint(raw) || isReasoningConstraintErrorMessage(raw)) {
|
||||
return false;
|
||||
}
|
||||
if (!looksLikeGenericContextOverflowError(raw)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isRawApiErrorPayload(raw) ||
|
||||
isLikelyHttpErrorText(raw) ||
|
||||
ERROR_PREFIX_RE.test(raw) ||
|
||||
CONTEXT_OVERFLOW_ERROR_HEAD_RE.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function getApiErrorPayloadFingerprint(raw?: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const payload = parseApiErrorPayload(raw);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return stableStringify(payload);
|
||||
}
|
||||
|
||||
export function isRawApiErrorPayload(raw?: string): boolean {
|
||||
return getApiErrorPayloadFingerprint(raw) !== null;
|
||||
}
|
||||
|
||||
function isLikelyProviderErrorType(type?: string): boolean {
|
||||
const normalized = normalizeOptionalLowercaseString(type);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.endsWith("_error");
|
||||
}
|
||||
|
||||
function shouldRewriteRawPayloadWithoutErrorContext(raw: string): boolean {
|
||||
if (raw.length > NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
if (!NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
const info = parseApiErrorInfo(raw);
|
||||
if (!info) {
|
||||
return false;
|
||||
}
|
||||
if (isLikelyProviderErrorType(info.type)) {
|
||||
return true;
|
||||
}
|
||||
if (info.httpCode) {
|
||||
const parsedCode = Number(info.httpCode);
|
||||
if (Number.isFinite(parsedCode) && parsedCode >= 400) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripFinalTagsFromText(text: unknown): string {
|
||||
const normalized = coerceChatContentText(text);
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
return stripFinalTags(normalized);
|
||||
return normalized ? stripFinalTags(normalized) : normalized;
|
||||
}
|
||||
|
||||
function stripToolCallsOmittedPlaceholderLines(text: string): string {
|
||||
@@ -400,39 +44,17 @@ function collapseConsecutiveDuplicateBlocks(text: string): string {
|
||||
if (blocks.length < 2) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||
const result: string[] = [];
|
||||
let lastNormalized: string | null = null;
|
||||
|
||||
for (const block of blocks) {
|
||||
const normalized = normalizeBlock(block);
|
||||
const normalized = block.trim().replace(/\s+/g, " ");
|
||||
if (lastNormalized && normalized === lastNormalized) {
|
||||
continue;
|
||||
}
|
||||
result.push(block.trim());
|
||||
lastNormalized = normalized;
|
||||
}
|
||||
|
||||
if (result.length === blocks.length) {
|
||||
return text;
|
||||
}
|
||||
return result.join("\n\n");
|
||||
}
|
||||
|
||||
export function isLikelyHttpErrorText(raw: string): boolean {
|
||||
if (isCloudflareOrHtmlErrorPage(raw)) {
|
||||
return true;
|
||||
}
|
||||
const status = extractLeadingHttpStatus(raw);
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
if (status.code < 400) {
|
||||
return false;
|
||||
}
|
||||
const message = normalizeLowercaseStringOrEmpty(status.rest);
|
||||
return HTTP_ERROR_HINTS.some((hint) => message.includes(hint));
|
||||
return result.length === blocks.length ? text : result.join("\n\n");
|
||||
}
|
||||
|
||||
export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string {
|
||||
@@ -440,92 +62,23 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
const errorContext = opts?.errorContext ?? false;
|
||||
const stripped = stripInboundMetadata(stripInternalRuntimeContext(stripFinalTagsFromText(raw)));
|
||||
const withoutToolCallXml = stripToolCallXmlTags(stripMinimaxToolCallXml(stripped), {
|
||||
stripFunctionCallsXmlPayloads: true,
|
||||
});
|
||||
// Replay repair may synthesize this placeholder to keep provider transcripts valid.
|
||||
// It is internal scaffolding, so drop standalone placeholder lines before delivery
|
||||
// while preserving ordinary inline mentions a user may be discussing.
|
||||
// It is internal scaffolding, so drop standalone placeholder lines before delivery.
|
||||
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
|
||||
const withoutInternalTraceLines = errorContext
|
||||
const withoutInternalTraceLines = opts?.errorContext
|
||||
? stripAssistantInternalTraceLines(withoutPlaceholder)
|
||||
: withoutPlaceholder;
|
||||
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(
|
||||
stripLegacyBracketToolCallBlocks(withoutInternalTraceLines),
|
||||
{ resolveProtectedRanges: findCodeRegions },
|
||||
);
|
||||
const trimmed = withoutToolCallBlocks.trim();
|
||||
if (!trimmed) {
|
||||
if (!withoutToolCallBlocks.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!errorContext && shouldRewriteRawPayloadWithoutErrorContext(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (errorContext) {
|
||||
const execDeniedMessage = formatExecDeniedUserMessage(trimmed);
|
||||
if (execDeniedMessage) {
|
||||
return execDeniedMessage;
|
||||
}
|
||||
|
||||
const diskSpaceCopy = formatDiskSpaceErrorCopy(trimmed);
|
||||
if (diskSpaceCopy) {
|
||||
return diskSpaceCopy;
|
||||
}
|
||||
|
||||
if (/incorrect role information|roles must alternate/i.test(trimmed)) {
|
||||
return (
|
||||
"Message ordering conflict - please try again. " +
|
||||
"If this persists, use /new to start a fresh session."
|
||||
);
|
||||
}
|
||||
|
||||
// Keep this sanitizer on generic overflow heuristics only so callers can
|
||||
// avoid importing the heavier provider-runtime-aware error classifier.
|
||||
if (shouldRewriteContextOverflowText(trimmed)) {
|
||||
return (
|
||||
"Context overflow: prompt too large for the model. " +
|
||||
"Try /reset (or /new) to start a fresh session, or use a larger-context model."
|
||||
);
|
||||
}
|
||||
|
||||
if (isBillingErrorMessage(trimmed)) {
|
||||
return BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (isGenericProviderInternalError(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (isInvalidStreamingEventOrderError(trimmed)) {
|
||||
return "LLM request failed: provider returned an invalid streaming response. Please try again.";
|
||||
}
|
||||
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (isStreamingJsonParseError(trimmed)) {
|
||||
return "LLM streaming response contained a malformed fragment. Please try again.";
|
||||
}
|
||||
if (ERROR_PREFIX_RE.test(trimmed)) {
|
||||
const prefixedCopy = formatRateLimitOrOverloadedErrorCopy(trimmed);
|
||||
if (prefixedCopy) {
|
||||
return prefixedCopy;
|
||||
}
|
||||
const transportCopy = formatTransportErrorCopy(trimmed);
|
||||
if (transportCopy) {
|
||||
return transportCopy;
|
||||
}
|
||||
// finish_reason/stop-reason `error` is a completed provider failure, not a timeout (#109218).
|
||||
if (isProviderCompletedErrorFinishReasonMessage(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (isTimeoutErrorMessage(trimmed)) {
|
||||
return "LLM request timed out.";
|
||||
}
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const withoutLeadingEmptyLines = withoutToolCallBlocks.replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
return collapseConsecutiveDuplicateBlocks(withoutLeadingEmptyLines);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { renderSanitizedUserFacingText } from "../failover/user-copy.js";
|
||||
import { sanitizeUserFacingText } from "./sanitize-user-facing-text.js";
|
||||
|
||||
/** Compose internal-text stripping with the canonical failover copy renderer. */
|
||||
export function renderUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string {
|
||||
return renderSanitizedUserFacingText(sanitizeUserFacingText(text, opts), opts);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Coverage for deciding when embedded run results should trigger model fallback.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../auto-reply/reply/agent-runner-failure-copy.js";
|
||||
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js";
|
||||
import { runWithModelFallback } from "../model-fallback-runner.js";
|
||||
import { classifyEmbeddedAgentRunResultForModelFallback } from "./result-fallback-classifier.js";
|
||||
|
||||
describe("classifyEmbeddedAgentRunResultForModelFallback", () => {
|
||||
@@ -127,25 +128,42 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies generic external runner failure text as fallback-worthy", () => {
|
||||
const result = classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
result: {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
},
|
||||
it("advances to the configured fallback after a generic external runner failure", async () => {
|
||||
const runs: Array<{ provider: string; model: string }> = [];
|
||||
const result = await runWithModelFallback({
|
||||
cfg: undefined,
|
||||
provider: "external",
|
||||
model: "primary",
|
||||
fallbacksOverride: ["external/fallback"],
|
||||
skipAuthProfileRuntime: true,
|
||||
run: async (provider, model) => {
|
||||
runs.push({ provider, model });
|
||||
return runs.length === 1
|
||||
? {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
meta: { durationMs: 1 },
|
||||
}
|
||||
: { payloads: [{ text: "fallback ok" }], meta: { durationMs: 1 } };
|
||||
},
|
||||
classifyResult: ({ provider, model, result: runResult }) =>
|
||||
classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider,
|
||||
model,
|
||||
result: runResult,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message:
|
||||
"claude-cli/claude-sonnet-4-6 ended with a generic external runner failure: " +
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
expect(runs).toEqual([
|
||||
{ provider: "external", model: "primary" },
|
||||
{ provider: "external", model: "fallback" },
|
||||
]);
|
||||
expect(result.result.payloads).toEqual([{ text: "fallback ok" }]);
|
||||
expect(result.attempts[0]).toMatchObject({
|
||||
provider: "external",
|
||||
model: "primary",
|
||||
reason: "format",
|
||||
code: "generic_external_run_failure",
|
||||
rawError: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
error: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,80 +213,60 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry generic external runner failure text mixed with non-text visible content", () => {
|
||||
const result = classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
result: {
|
||||
payloads: [
|
||||
{
|
||||
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
mediaUrl: "https://example.com/failure-screenshot.png",
|
||||
channelData: { delivered: true },
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
},
|
||||
it.each([
|
||||
{
|
||||
name: "non-text visible content",
|
||||
payload: {
|
||||
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
mediaUrl: "https://example.com/failure-screenshot.png",
|
||||
channelData: { delivered: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry generic external runner failure text mixed with interactive content", () => {
|
||||
const result = classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
result: {
|
||||
payloads: [
|
||||
{
|
||||
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
interactive: { type: "button", label: "Retry" },
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "interactive content",
|
||||
payload: {
|
||||
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
interactive: { type: "button", label: "Retry" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
},
|
||||
])("does not retry generic external runner failure text with $name", ({ payload }) => {
|
||||
expect(
|
||||
classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "external",
|
||||
model: "primary",
|
||||
result: { payloads: [payload], meta: { durationMs: 42 } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry generic external runner failure text after committed delivery", () => {
|
||||
const result = classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
result: {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
messagingToolSentTexts: ["already delivered"],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
expect(
|
||||
classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "external",
|
||||
model: "primary",
|
||||
result: {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
messagingToolSentTexts: ["already delivered"],
|
||||
meta: { durationMs: 42 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves hook block results with generic external runner failure text", () => {
|
||||
const result = classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
result: {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
error: {
|
||||
kind: "hook_block",
|
||||
message: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
it("preserves hook blocks with generic external runner failure text", () => {
|
||||
expect(
|
||||
classifyEmbeddedAgentRunResultForModelFallback({
|
||||
provider: "external",
|
||||
model: "primary",
|
||||
result: {
|
||||
payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
error: { kind: "hook_block", message: GENERIC_EXTERNAL_RUN_FAILURE_TEXT },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves hook block results with auth-like error payload text", () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Classifies embedded-agent run results for model fallback decisions.
|
||||
*/
|
||||
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../auto-reply/reply/agent-runner-failure-copy.js";
|
||||
/** Classifies embedded-agent run results for model fallback decisions. */
|
||||
import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js";
|
||||
import { classifyFailoverReason } from "../failover/classify.js";
|
||||
import type { FailoverReason } from "../failover/signal.js";
|
||||
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js";
|
||||
import type { ModelFallbackResultClassification } from "../model-fallback-attempt.js";
|
||||
import {
|
||||
hasCommittedOutboundDeliveryEvidence,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
resolveProfilesUnavailableReason,
|
||||
resolveSubscriptionAuthModeForProfiles,
|
||||
} from "../../auth-profiles.js";
|
||||
import { formatAuthProfileFailureMessage } from "../../auth-profiles/failure-copy.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isFailoverErrorMessage,
|
||||
@@ -21,11 +20,13 @@ import {
|
||||
} from "../../embedded-agent-helpers.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "../../failover-error.js";
|
||||
import { shouldUseTransientCooldownProbeSlot } from "../../failover-policy.js";
|
||||
import { renderAuthProfileFailoverCopy } from "../../failover/user-copy.js";
|
||||
import {
|
||||
getApiKeyForModel,
|
||||
MissingProviderAuthError,
|
||||
type ResolvedProviderAuth,
|
||||
} from "../../model-auth.js";
|
||||
import { buildProviderAuthRecoveryHint } from "../../provider-auth-recovery-hint.js";
|
||||
import { providerModelRouteAcceptsAuthMode } from "../../provider-model-route-auth.js";
|
||||
import {
|
||||
applyPreparedRuntimeAuthToModel,
|
||||
@@ -403,14 +404,19 @@ export function createEmbeddedRunAuthController(params: {
|
||||
});
|
||||
const message =
|
||||
failoverParams.message?.trim() ||
|
||||
formatAuthProfileFailureMessage({
|
||||
renderAuthProfileFailoverCopy({
|
||||
reason,
|
||||
provider,
|
||||
allInCooldown: failoverParams.allInCooldown,
|
||||
cause: failoverParams.error,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: process.env,
|
||||
causeText: failoverParams.error
|
||||
? formatErrorMessage(failoverParams.error).trim()
|
||||
: undefined,
|
||||
recoveryHint: buildProviderAuthRecoveryHint({
|
||||
provider,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: process.env,
|
||||
}),
|
||||
});
|
||||
if (params.fallbackConfigured) {
|
||||
const authMode =
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sanitizeAssistantVisibleText,
|
||||
} from "../shared/text/assistant-visible-text.js";
|
||||
import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js";
|
||||
import type { AgentMessage } from "./runtime/index.js";
|
||||
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
|
||||
@@ -41,7 +42,9 @@ export function sanitizeAssistantVisibleStreamText(text: string): string {
|
||||
|
||||
function finalizeAssistantExtraction(msg: AssistantMessage, extracted: string): string {
|
||||
const errorContext = msg.stopReason === "error";
|
||||
return sanitizeUserFacingText(extracted, { errorContext });
|
||||
return errorContext
|
||||
? renderUserFacingText(extracted, { errorContext: true })
|
||||
: sanitizeUserFacingText(extracted);
|
||||
}
|
||||
|
||||
type AssistantTextExtractionResult = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Covers provider-specific failover matcher regressions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRateLimitOrOverloadedErrorCopy } from "../embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isAuthErrorMessage,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
isTimeoutErrorMessage,
|
||||
matchesFormatErrorPattern,
|
||||
} from "./classify.js";
|
||||
import { renderRateLimitOrOverloadedCopy } from "./user-copy.js";
|
||||
|
||||
describe("matchesFormatErrorPattern", () => {
|
||||
it("retains the direct format compatibility predicate", () => {
|
||||
@@ -276,16 +276,17 @@ describe("HTTP 429 overload wording (#98101)", () => {
|
||||
'{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}';
|
||||
expect(classifyFailoverReason(message)).toBe("rate_limit");
|
||||
expect(classifyFailoverReason(`HTTP 429: ${message}`)).toBe("rate_limit");
|
||||
expect(formatRateLimitOrOverloadedErrorCopy(message)).toBe(
|
||||
"The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe(
|
||||
"⚠️ API rate limit reached. Please try again later.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves actionable retry details when a rate limit also mentions overload", () => {
|
||||
expect(
|
||||
formatRateLimitOrOverloadedErrorCopy(
|
||||
"429 rate limit: service overloaded, try again in 30 seconds",
|
||||
),
|
||||
renderRateLimitOrOverloadedCopy({
|
||||
reason: "rate_limit",
|
||||
raw: "429 rate limit: service overloaded, try again in 30 seconds",
|
||||
}),
|
||||
).toBe("⚠️ rate limit: service overloaded, try again in 30 seconds");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const failoverClassificationCorpus = [
|
||||
...legacyBillingBCases,
|
||||
...legacyProviderMatcherCases,
|
||||
];
|
||||
import { formatRateLimitOrOverloadedErrorCopy } from "../embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderRateLimitOrOverloadedCopy } from "./user-copy.js";
|
||||
|
||||
function classifyReplyRequest(signal: FailoverSignal) {
|
||||
return resolveReplyFailoverFacts(signal, signal.message ?? "").providerRequestError;
|
||||
@@ -107,16 +107,16 @@ describe("cross-layer drift (documents current behavior, see refactor-02)", () =
|
||||
});
|
||||
});
|
||||
|
||||
it("uses rate-limit retry semantics but overloaded user copy", () => {
|
||||
it("renders rate-limit copy from the classified reason", () => {
|
||||
const message = "429 Too Many Requests: model overloaded";
|
||||
|
||||
// BUG(refactor-02): retry classification and user-copy precedence are inverted.
|
||||
// FIXED(refactor-02): user copy follows the canonical failover reason.
|
||||
expect(classifyFailoverSignal({ message })).toEqual({
|
||||
kind: "reason",
|
||||
reason: "rate_limit",
|
||||
});
|
||||
expect(formatRateLimitOrOverloadedErrorCopy(message)).toBe(
|
||||
"The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe(
|
||||
"⚠️ API rate limit reached. Please try again later.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
renderBillingReplyCopy,
|
||||
renderCliTimeoutReplyCopy,
|
||||
renderMissingApiKeyReplyCopy,
|
||||
renderRateLimitOrOverloadedCopy,
|
||||
renderRateLimitReplyCopy,
|
||||
} from "./user-copy.js";
|
||||
|
||||
describe("failover user copy", () => {
|
||||
it("renders transient copy from the classified reason", () => {
|
||||
const raw = "429 Too Many Requests: model overloaded";
|
||||
expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw })).toBe(
|
||||
"⚠️ API rate limit reached. Please try again later.",
|
||||
);
|
||||
expect(renderRateLimitOrOverloadedCopy({ reason: "overloaded", raw })).toBe(
|
||||
"The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves actionable provider retry detail for classified rate limits", () => {
|
||||
expect(
|
||||
renderRateLimitOrOverloadedCopy({
|
||||
reason: "rate_limit",
|
||||
raw: "429 rate limit: service overloaded, try again in 30 seconds",
|
||||
}),
|
||||
).toBe("⚠️ rate limit: service overloaded, try again in 30 seconds");
|
||||
});
|
||||
|
||||
it("renders structured cooldown durations and exhausted model sets", () => {
|
||||
const now = 1_000_000;
|
||||
expect(
|
||||
renderRateLimitReplyCopy({
|
||||
message: "limited",
|
||||
reason: "rate_limit",
|
||||
attempts: [{ provider: "openai", model: "gpt-a", reason: "rate_limit" }],
|
||||
cooldownExpiry: now + 45_000,
|
||||
nowMs: now,
|
||||
}),
|
||||
).toBe("⚠️ Rate-limited — ready in ~45s. Please wait a moment.");
|
||||
expect(
|
||||
renderRateLimitReplyCopy({
|
||||
message: "limited",
|
||||
reason: "rate_limit",
|
||||
attempts: [
|
||||
{ provider: "openai", model: "gpt-a", reason: "rate_limit" },
|
||||
{ provider: "anthropic", model: "claude-b", reason: "overloaded" },
|
||||
],
|
||||
nowMs: now,
|
||||
}),
|
||||
).toBe(
|
||||
"⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses neutral billing copy for subscription credentials", () => {
|
||||
expect(
|
||||
renderBillingReplyCopy({
|
||||
provider: "Anthropic",
|
||||
model: "claude",
|
||||
authMode: "oauth",
|
||||
}),
|
||||
).toBe(
|
||||
"⚠️ Anthropic (claude) returned a billing error — check your account for subscription or usage limits, then try again.",
|
||||
);
|
||||
expect(renderBillingReplyCopy({})).toBe(BILLING_ERROR_USER_MESSAGE);
|
||||
});
|
||||
|
||||
it("renders provider-safe missing-key guidance", () => {
|
||||
expect(renderMissingApiKeyReplyCopy({ provider: "openai", providerGuidance: true })).toContain(
|
||||
"Missing API key for OpenAI on the gateway",
|
||||
);
|
||||
expect(renderMissingApiKeyReplyCopy({ provider: "provider-with-secret-name" })).toBe(
|
||||
"⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders typed CLI timeout context without losing partial-work warnings", () => {
|
||||
expect(
|
||||
renderCliTimeoutReplyCopy({
|
||||
message: "openai/gpt-5.6-sol: CLI exceeded timeout (90s) and was terminated",
|
||||
provider: "codex-cli",
|
||||
cliTimeout: {
|
||||
mode: "overall",
|
||||
timeoutSeconds: 90,
|
||||
observedActivity: true,
|
||||
activeToolCount: 1,
|
||||
backgroundTaskCount: 2,
|
||||
},
|
||||
replayPrevented: true,
|
||||
}),
|
||||
).toBe(
|
||||
"⚠️ CLI turn (routing openai/gpt-5.6-sol): timed out after 90s (overall turn limit). The gateway is unaffected. It also stopped 2 CLI background tasks and 1 active CLI tool call; that work shares the parent CLI process. Effects may be partial; check before retrying. OpenClaw did not replay this turn automatically. For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise `agents.defaults.timeoutSeconds`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,697 @@
|
||||
import { stableStringify } from "@openclaw/normalization-core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
extractLeadingHttpStatus,
|
||||
formatRawAssistantErrorForUi,
|
||||
isCloudflareOrHtmlErrorPage,
|
||||
isGenericProviderInternalError,
|
||||
MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE,
|
||||
parseApiErrorInfo,
|
||||
parseApiErrorPayload,
|
||||
} from "../../shared/assistant-error-format.js";
|
||||
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
|
||||
import type { CliTimeoutContext, FallbackAttemptRecord } from "../failover-error.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isPeriodicUsageLimitErrorMessage,
|
||||
isProviderCompletedErrorFinishReasonMessage,
|
||||
} from "./classify.js";
|
||||
import {
|
||||
classifyProviderRequestFacets,
|
||||
type ProviderRequestFacet,
|
||||
} from "./request-error-facets.js";
|
||||
import type { FailoverClassification, FailoverReason } from "./signal.js";
|
||||
|
||||
type FailoverUserCopyContext = {
|
||||
raw?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
authMode?: string;
|
||||
};
|
||||
|
||||
type FailoverBaseCopyRenderer = (context: FailoverUserCopyContext) => string | undefined;
|
||||
|
||||
const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later.";
|
||||
export const AUTH_INVALID_TOKEN_USER_TEXT =
|
||||
"Authentication failed (provider returned HTTP 401). " +
|
||||
"Your provider token may have expired — try the request again in a moment. " +
|
||||
"If the failure persists, re-authenticate this provider.";
|
||||
const MODEL_CAPACITY_ERROR_USER_MESSAGE =
|
||||
"⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
|
||||
const OVERLOADED_ERROR_USER_MESSAGE =
|
||||
"The AI service is temporarily overloaded. Please try again in a moment.";
|
||||
const RATE_LIMIT_RETRY_MESSAGE =
|
||||
"⚠️ The model request was rate-limited. Please try again in a few minutes.";
|
||||
const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i;
|
||||
const RATE_LIMIT_SPECIFIC_HINT_RE =
|
||||
/\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i;
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
|
||||
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
|
||||
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
|
||||
const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384;
|
||||
const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i;
|
||||
|
||||
/** Format billing copy with optional provider/model and credential context. */
|
||||
export function formatBillingErrorMessage(
|
||||
provider?: string,
|
||||
model?: string,
|
||||
authMode?: string,
|
||||
): string {
|
||||
const providerName = provider?.trim();
|
||||
const modelName = model?.trim();
|
||||
const providerLabel =
|
||||
providerName && modelName ? `${providerName} (${modelName})` : providerName || undefined;
|
||||
const isSubscriptionAuth = authMode === "oauth" || authMode === "token";
|
||||
if (isSubscriptionAuth) {
|
||||
return providerLabel
|
||||
? `⚠️ ${providerLabel} returned a billing error — check your account for subscription or usage limits, then try again.`
|
||||
: "⚠️ API provider returned a billing error — check your account for subscription or usage limits, then try again.";
|
||||
}
|
||||
return providerLabel
|
||||
? `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.`
|
||||
: "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key.";
|
||||
}
|
||||
|
||||
export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage();
|
||||
|
||||
function extractProviderRateLimitMessage(raw: string): string | undefined {
|
||||
const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim();
|
||||
const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix);
|
||||
const candidate =
|
||||
info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix);
|
||||
if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
if (isCloudflareOrHtmlErrorPage(withoutPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (
|
||||
trimmed.length > 300 ||
|
||||
trimmed.startsWith("{") ||
|
||||
/^(?:<!doctype\s+html\b|<html\b)/i.test(trimmed)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return `⚠️ ${trimmed}`;
|
||||
}
|
||||
|
||||
function renderRateLimitBaseCopy(context: FailoverUserCopyContext): string {
|
||||
const raw = context.raw ?? "";
|
||||
if (MODEL_CAPACITY_ERROR_RE.test(raw)) {
|
||||
return MODEL_CAPACITY_ERROR_USER_MESSAGE;
|
||||
}
|
||||
return extractProviderRateLimitMessage(raw) ?? RATE_LIMIT_ERROR_USER_MESSAGE;
|
||||
}
|
||||
|
||||
const FAILOVER_REASON_BASE_COPY = {
|
||||
auth: () => AUTH_INVALID_TOKEN_USER_TEXT,
|
||||
auth_permanent: () => AUTH_INVALID_TOKEN_USER_TEXT,
|
||||
format: () => "LLM request failed: provider rejected the request schema or tool payload.",
|
||||
rate_limit: renderRateLimitBaseCopy,
|
||||
overloaded: (context) =>
|
||||
MODEL_CAPACITY_ERROR_RE.test(context.raw ?? "")
|
||||
? MODEL_CAPACITY_ERROR_USER_MESSAGE
|
||||
: OVERLOADED_ERROR_USER_MESSAGE,
|
||||
billing: (context) =>
|
||||
formatBillingErrorMessage(context.provider, context.model, context.authMode),
|
||||
server_error: () => "LLM request failed: provider returned an internal error.",
|
||||
timeout: () => "LLM request timed out.",
|
||||
tls_certificate: () =>
|
||||
"LLM request failed: TLS certificate validation rejected the provider endpoint. Check the endpoint hostname, proxy, and local certificate trust.",
|
||||
context_overflow: () =>
|
||||
"Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.",
|
||||
model_not_found: () =>
|
||||
"The selected model was not found by the provider. Check the model id or choose a different model.",
|
||||
session_expired: () => "The provider session expired. Start a new session and try again.",
|
||||
empty_response: () => "The model returned an empty response. Please try again.",
|
||||
no_error_details: () => "LLM request failed with an unknown error.",
|
||||
unclassified: () => "LLM request failed.",
|
||||
unknown: () => "LLM request failed with an unknown error.",
|
||||
} satisfies Record<FailoverReason, FailoverBaseCopyRenderer>;
|
||||
|
||||
function renderFailoverBaseCopy(
|
||||
reason: FailoverReason,
|
||||
context: FailoverUserCopyContext = {},
|
||||
): string | undefined {
|
||||
return FAILOVER_REASON_BASE_COPY[reason](context);
|
||||
}
|
||||
|
||||
/** Render rate-limit versus overload copy from the canonical classified reason. */
|
||||
export function renderRateLimitOrOverloadedCopy(params: {
|
||||
reason: Extract<FailoverReason, "rate_limit" | "overloaded">;
|
||||
raw?: string;
|
||||
}): string {
|
||||
return (
|
||||
renderFailoverBaseCopy(params.reason, { raw: params.raw }) ?? RATE_LIMIT_ERROR_USER_MESSAGE
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTransportErrorCopy(raw: string): string | undefined {
|
||||
if (!raw || isCloudflareOrHtmlErrorPage(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (
|
||||
/\beconnrefused\b/i.test(raw) ||
|
||||
lower.includes("connection refused") ||
|
||||
lower.includes("actively refused")
|
||||
) {
|
||||
return "LLM request failed: connection refused by the provider endpoint.";
|
||||
}
|
||||
if (
|
||||
/\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) ||
|
||||
lower.includes("socket hang up") ||
|
||||
lower.includes("connection reset") ||
|
||||
lower.includes("connection aborted")
|
||||
) {
|
||||
return "LLM request failed: network connection was interrupted.";
|
||||
}
|
||||
if (
|
||||
/\benotfound\b|\beai_again\b/i.test(raw) ||
|
||||
lower.includes("getaddrinfo") ||
|
||||
lower.includes("no such host") ||
|
||||
lower.includes("dns")
|
||||
) {
|
||||
return "LLM request failed: DNS lookup for the provider endpoint failed.";
|
||||
}
|
||||
if (
|
||||
/\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) ||
|
||||
lower.includes("network is unreachable") ||
|
||||
lower.includes("host is unreachable")
|
||||
) {
|
||||
return "LLM request failed: the provider endpoint is unreachable from this host.";
|
||||
}
|
||||
if (
|
||||
lower.includes("fetch failed") ||
|
||||
lower.includes("connection error") ||
|
||||
lower.includes("network request failed")
|
||||
) {
|
||||
return "LLM request failed: network connection error.";
|
||||
}
|
||||
if (raw.includes("网络错误") || raw.includes("网络异常") || raw.includes("连接错误")) {
|
||||
return "LLM request failed: provider reported a network error.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatDiskSpaceErrorCopy(raw: string): string | undefined {
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
return /\benospc\b/i.test(raw) ||
|
||||
lower.includes("no space left on device") ||
|
||||
lower.includes("disk full")
|
||||
? "OpenClaw could not write local session data because the disk is full. Free some disk space and try again."
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isInvalidStreamingEventOrderError(raw: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
return (
|
||||
lower.includes("unexpected event order") &&
|
||||
lower.includes("message_start") &&
|
||||
lower.includes("message_stop")
|
||||
);
|
||||
}
|
||||
|
||||
export function isStreamingJsonParseError(raw: string): boolean {
|
||||
return raw.trim() === MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
export function getApiErrorPayloadFingerprint(raw?: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const payload = parseApiErrorPayload(raw);
|
||||
return payload ? stableStringify(payload) : null;
|
||||
}
|
||||
|
||||
export function isRawApiErrorPayload(raw?: string): boolean {
|
||||
return getApiErrorPayloadFingerprint(raw) !== null;
|
||||
}
|
||||
|
||||
/** Recognize provider HTTP/HTML failures from canonical classification facts. */
|
||||
export function isLikelyHttpErrorText(raw: string): boolean {
|
||||
if (isCloudflareOrHtmlErrorPage(raw)) {
|
||||
return true;
|
||||
}
|
||||
const status = extractLeadingHttpStatus(raw);
|
||||
return Boolean(
|
||||
status &&
|
||||
status.code >= 400 &&
|
||||
(classifyFailoverReason(raw) !== null ||
|
||||
classifyProviderRequestFacets({ status: status.code, message: raw }) !== null),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRewriteContextOverflowText(raw: string): boolean {
|
||||
if (classifyFailoverReason(raw) !== "context_overflow") {
|
||||
return false;
|
||||
}
|
||||
const status = extractLeadingHttpStatus(raw);
|
||||
return (
|
||||
isRawApiErrorPayload(raw) ||
|
||||
Boolean(status && status.code >= 400) ||
|
||||
ERROR_PREFIX_RE.test(raw) ||
|
||||
CONTEXT_OVERFLOW_ERROR_HEAD_RE.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRewriteRawPayloadWithoutErrorContext(raw: string): boolean {
|
||||
if (
|
||||
raw.length > NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH ||
|
||||
!NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE.test(raw)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const info = parseApiErrorInfo(raw);
|
||||
const normalizedType = normalizeLowercaseStringOrEmpty(info?.type);
|
||||
if (normalizedType.endsWith("_error")) {
|
||||
return true;
|
||||
}
|
||||
const code = Number(info?.httpCode);
|
||||
return Number.isFinite(code) && code >= 400;
|
||||
}
|
||||
|
||||
/** Sanitize presentation text, then render error copy from classified facts when requested. */
|
||||
export function renderSanitizedUserFacingText(
|
||||
sanitized: string,
|
||||
opts?: { errorContext?: boolean },
|
||||
): string {
|
||||
if (!sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
const trimmed = sanitized.trim();
|
||||
if (!opts?.errorContext) {
|
||||
return shouldRewriteRawPayloadWithoutErrorContext(trimmed)
|
||||
? formatRawAssistantErrorForUi(trimmed)
|
||||
: sanitized;
|
||||
}
|
||||
const execDenied = formatExecDeniedUserMessage(trimmed);
|
||||
if (execDenied) {
|
||||
return execDenied;
|
||||
}
|
||||
const diskSpace = formatDiskSpaceErrorCopy(trimmed);
|
||||
if (diskSpace) {
|
||||
return diskSpace;
|
||||
}
|
||||
if (/incorrect role information|roles must alternate/i.test(trimmed)) {
|
||||
return "Message ordering conflict - please try again. If this persists, use /new to start a fresh session.";
|
||||
}
|
||||
if (shouldRewriteContextOverflowText(trimmed)) {
|
||||
return renderFailoverBaseCopy("context_overflow") ?? trimmed;
|
||||
}
|
||||
const reason = classifyFailoverReason(trimmed);
|
||||
if (reason === "billing" || reason === "rate_limit" || reason === "overloaded") {
|
||||
return renderFailoverBaseCopy(reason, { raw: trimmed }) ?? trimmed;
|
||||
}
|
||||
if (isGenericProviderInternalError(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (isInvalidStreamingEventOrderError(trimmed)) {
|
||||
return "LLM request failed: provider returned an invalid streaming response. Please try again.";
|
||||
}
|
||||
const status = extractLeadingHttpStatus(trimmed);
|
||||
if (isRawApiErrorPayload(trimmed) || (status && status.code >= 400 && reason)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (isStreamingJsonParseError(trimmed)) {
|
||||
return "LLM streaming response contained a malformed fragment. Please try again.";
|
||||
}
|
||||
if (ERROR_PREFIX_RE.test(trimmed)) {
|
||||
const transport = formatTransportErrorCopy(trimmed);
|
||||
if (transport) {
|
||||
return transport;
|
||||
}
|
||||
if (isProviderCompletedErrorFinishReasonMessage(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
if (reason === "timeout") {
|
||||
return renderFailoverBaseCopy("timeout") ?? trimmed;
|
||||
}
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
|
||||
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
|
||||
export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT =
|
||||
"⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available.";
|
||||
export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session.";
|
||||
const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again.";
|
||||
const PROVIDER_INTERNAL_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening.";
|
||||
const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`;
|
||||
const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE =
|
||||
"⚠️ The configured model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. This needs a config update (agents.defaults.model); retrying or starting a new session won't fix it.";
|
||||
|
||||
const PROVIDER_REQUEST_COPY = {
|
||||
"quota-429": PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
|
||||
"conversation-state": PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
"provider-internal": PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
"provider-internal-503": PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
} satisfies Record<ProviderRequestFacet, string>;
|
||||
|
||||
function renderProviderRequestFailureCopy(params: {
|
||||
classification: FailoverClassification | null;
|
||||
facet: ProviderRequestFacet | null;
|
||||
status?: number;
|
||||
}): string | undefined {
|
||||
const reason =
|
||||
params.classification?.kind === "reason" ? params.classification.reason : undefined;
|
||||
if (reason === "auth" && params.status === 401) {
|
||||
return PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (reason === "model_not_found") {
|
||||
return PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE;
|
||||
}
|
||||
return params.facet ? PROVIDER_REQUEST_COPY[params.facet] : undefined;
|
||||
}
|
||||
|
||||
type ProviderRequestErrorCode =
|
||||
| "provider_authentication_error"
|
||||
| "provider_conversation_state_error"
|
||||
| "provider_internal_error"
|
||||
| "provider_model_unavailable"
|
||||
| "provider_rate_limit_or_quota_error";
|
||||
|
||||
export function resolveProviderRequestFailureCopy(params: {
|
||||
classification: FailoverClassification | null;
|
||||
facet: ProviderRequestFacet | null;
|
||||
status?: number;
|
||||
technicalMessage: string;
|
||||
}) {
|
||||
const userMessage = renderProviderRequestFailureCopy(params);
|
||||
if (!userMessage) {
|
||||
return undefined;
|
||||
}
|
||||
const reason =
|
||||
params.classification?.kind === "reason" ? params.classification.reason : undefined;
|
||||
const code: ProviderRequestErrorCode =
|
||||
reason === "auth" && params.status === 401
|
||||
? "provider_authentication_error"
|
||||
: reason === "model_not_found"
|
||||
? "provider_model_unavailable"
|
||||
: params.facet === "quota-429"
|
||||
? "provider_rate_limit_or_quota_error"
|
||||
: params.facet === "conversation-state"
|
||||
? "provider_conversation_state_error"
|
||||
: "provider_internal_error";
|
||||
return {
|
||||
code,
|
||||
userMessage,
|
||||
technicalMessage: params.technicalMessage,
|
||||
...(params.facet === "provider-internal-503" ? { allowTransientHttpRetry: true as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type ReplyFallbackAttempt = FallbackAttemptRecord & { authMode?: string };
|
||||
|
||||
function extractCodexUsageLimitErrorMessage(
|
||||
attempts: readonly ReplyFallbackAttempt[],
|
||||
directMessage: string,
|
||||
directReason: FailoverReason | undefined,
|
||||
directProvider: string | undefined,
|
||||
sanitizeText?: (text: string) => string,
|
||||
): string | undefined {
|
||||
const attempt = attempts.find(
|
||||
(candidate) =>
|
||||
candidate.provider === "openai" && candidate.reason === "rate_limit" && candidate.error,
|
||||
);
|
||||
const text =
|
||||
attempt?.error ??
|
||||
(directProvider === "openai" && directReason === "rate_limit" ? directMessage : undefined);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const message = renderSanitizedUserFacingText(sanitizeText?.(text) ?? text, {
|
||||
errorContext: true,
|
||||
})
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
const truncated = message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message;
|
||||
return truncated.startsWith("⚠️") ? truncated : `⚠️ ${truncated}`;
|
||||
}
|
||||
|
||||
/** Render the reply surface's rate-limit copy, including structured cooldown context. */
|
||||
export function renderRateLimitReplyCopy(params: {
|
||||
message: string;
|
||||
reason?: FailoverReason;
|
||||
provider?: string;
|
||||
attempts?: readonly ReplyFallbackAttempt[];
|
||||
cooldownExpiry?: number | null;
|
||||
nowMs?: number;
|
||||
sanitizeText?: (text: string) => string;
|
||||
}): string {
|
||||
const attempts = params.attempts ?? [];
|
||||
const usageLimit = extractCodexUsageLimitErrorMessage(
|
||||
attempts,
|
||||
params.message,
|
||||
params.reason,
|
||||
params.provider,
|
||||
params.sanitizeText,
|
||||
);
|
||||
if (usageLimit) {
|
||||
return usageLimit;
|
||||
}
|
||||
if (attempts.some((attempt) => attempt.reason === "billing") || params.reason === "billing") {
|
||||
return BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (attempts.length === 0) {
|
||||
if (params.reason === "rate_limit" && isPeriodicUsageLimitErrorMessage(params.message)) {
|
||||
const providerMessage = renderSanitizedUserFacingText(
|
||||
params.sanitizeText?.(params.message) ?? params.message,
|
||||
{ errorContext: true },
|
||||
);
|
||||
return providerMessage.startsWith("⚠️") ? providerMessage : `⚠️ ${providerMessage}`;
|
||||
}
|
||||
return RATE_LIMIT_RETRY_MESSAGE;
|
||||
}
|
||||
const expiry = params.cooldownExpiry;
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
if (typeof expiry === "number" && expiry > nowMs) {
|
||||
const secsLeft = Math.max(1, Math.ceil((expiry - nowMs) / 1000));
|
||||
return secsLeft <= 60
|
||||
? `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.`
|
||||
: `⚠️ Rate-limited — ready in ~${Math.ceil(secsLeft / 60)} min. Please try again shortly.`;
|
||||
}
|
||||
const attemptedModels = new Set(
|
||||
attempts.map((attempt) => `${attempt.provider}/${attempt.model}`),
|
||||
);
|
||||
return attemptedModels.size > 1 &&
|
||||
attempts.every((attempt) => attempt.reason === "rate_limit" || attempt.reason === "overloaded")
|
||||
? "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes."
|
||||
: RATE_LIMIT_RETRY_MESSAGE;
|
||||
}
|
||||
|
||||
export function renderBillingReplyCopy(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
authMode?: string;
|
||||
attempts?: readonly ReplyFallbackAttempt[];
|
||||
}): string {
|
||||
const attempts = params.attempts ?? [];
|
||||
const billingFailure =
|
||||
attempts.length > 0
|
||||
? attempts.find(
|
||||
(attempt) =>
|
||||
attempt.reason === "billing" &&
|
||||
(attempt.authMode === "oauth" || attempt.authMode === "token"),
|
||||
)
|
||||
: params.authMode === "oauth" || params.authMode === "token"
|
||||
? params
|
||||
: undefined;
|
||||
return billingFailure &&
|
||||
(billingFailure.authMode === "oauth" || billingFailure.authMode === "token")
|
||||
? formatBillingErrorMessage(
|
||||
billingFailure.provider,
|
||||
billingFailure.model,
|
||||
billingFailure.authMode,
|
||||
)
|
||||
: BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
|
||||
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
|
||||
|
||||
export function renderMissingApiKeyReplyCopy(params?: {
|
||||
provider: string;
|
||||
providerGuidance?: boolean;
|
||||
}): string | null {
|
||||
const provider = params?.provider.trim().toLowerCase();
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
if (provider === "openai" && params?.providerGuidance) {
|
||||
return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-sol` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.";
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.';
|
||||
}
|
||||
return SAFE_MISSING_API_KEY_PROVIDERS.has(provider)
|
||||
? `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`
|
||||
: "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.";
|
||||
}
|
||||
|
||||
const CLI_BACKEND_NO_OUTPUT_STALL_RE =
|
||||
/\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu;
|
||||
const CLI_BACKEND_OVERALL_TIMEOUT_RE =
|
||||
/\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu;
|
||||
const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu;
|
||||
|
||||
export function renderCliTimeoutReplyCopy(params: {
|
||||
message: string;
|
||||
cliTimeout?: CliTimeoutContext;
|
||||
provider?: string;
|
||||
replayPrevented?: boolean;
|
||||
}): string | null {
|
||||
const stall = params.message.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
|
||||
const overall = params.message.match(CLI_BACKEND_OVERALL_TIMEOUT_RE);
|
||||
const timeout = params.cliTimeout;
|
||||
const seconds = timeout?.timeoutSeconds ?? Number((stall ?? overall)?.[1]);
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
const routedModelRef = params.message.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1];
|
||||
const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : "";
|
||||
const mode = timeout?.mode ?? (stall ? "no-output" : "overall");
|
||||
const stoppedWork: string[] = [];
|
||||
if (timeout?.backgroundTaskCount) {
|
||||
stoppedWork.push(
|
||||
`${timeout.backgroundTaskCount} CLI background ${timeout.backgroundTaskCount === 1 ? "task" : "tasks"}`,
|
||||
);
|
||||
}
|
||||
if (timeout?.activeToolCount) {
|
||||
stoppedWork.push(
|
||||
`${timeout.activeToolCount} active CLI tool ${timeout.activeToolCount === 1 ? "call" : "calls"}`,
|
||||
);
|
||||
}
|
||||
let workStatus =
|
||||
stoppedWork.length > 0
|
||||
? ` It also stopped ${stoppedWork.join(" and ")}; that work shares the parent CLI process. Effects may be partial; check before retrying.`
|
||||
: timeout?.observedActivity
|
||||
? " The CLI had already begun work, so effects may be partial; check before retrying."
|
||||
: "";
|
||||
if (params.replayPrevented) {
|
||||
workStatus += " OpenClaw did not replay this turn automatically.";
|
||||
}
|
||||
return mode === "no-output"
|
||||
? `⚠️ CLI subprocess${routingSuffix}: no output for ${seconds}s, so the no-output watchdog stopped it. This is separate from the overall agent timeout; the gateway is unaffected.${workStatus} Check for an interactive prompt. The CLI backend ${params.provider ?? "<id>"} produced no output before its watchdog expired.`
|
||||
: `⚠️ CLI turn${routingSuffix}: timed out after ${seconds}s (overall turn limit). The gateway is unaffected.${workStatus} For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise \`agents.defaults.timeoutSeconds\`.`;
|
||||
}
|
||||
|
||||
type AuthProfileFailureCopyParams = {
|
||||
reason: FailoverReason;
|
||||
provider: string;
|
||||
allInCooldown: boolean;
|
||||
causeText?: string;
|
||||
recoveryHint?: string;
|
||||
};
|
||||
|
||||
const AUTH_PROFILE_COOLDOWN_COPY = {
|
||||
auth: (provider: string) =>
|
||||
`Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`,
|
||||
auth_permanent: (provider: string) => `${provider} isn't accepting your saved login anymore.`,
|
||||
format: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
rate_limit: (provider: string) =>
|
||||
`${provider} is asking us to slow down. Please wait a moment before trying again.`,
|
||||
overloaded: (provider: string) =>
|
||||
`${provider} is overloaded right now. Please wait a moment before trying again.`,
|
||||
billing: (provider: string) =>
|
||||
`${provider} rejected the request — looks like a billing issue on the account.`,
|
||||
server_error: (provider: string) =>
|
||||
`${provider} is having issues right now. Please wait a moment before trying again.`,
|
||||
timeout: (provider: string) =>
|
||||
`${provider} hasn't been responding. Please wait a moment before trying again.`,
|
||||
tls_certificate: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
context_overflow: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
model_not_found: (provider: string) => `${provider} can't find the model you're using right now.`,
|
||||
session_expired: (provider: string) =>
|
||||
`Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`,
|
||||
empty_response: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
no_error_details: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
unclassified: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
unknown: (provider: string) =>
|
||||
`Couldn't reach ${provider} with any of your saved logins right now.`,
|
||||
} satisfies Record<FailoverReason, (provider: string) => string>;
|
||||
|
||||
const AUTH_PROFILE_DIRECT_COPY: Partial<Record<FailoverReason, (provider: string) => string>> = {
|
||||
auth: (provider) =>
|
||||
`Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`,
|
||||
auth_permanent: (provider) => `${provider} isn't accepting your saved login.`,
|
||||
billing: (provider) =>
|
||||
`${provider} rejected the request — looks like a billing issue on the account.`,
|
||||
session_expired: (provider) =>
|
||||
`Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`,
|
||||
};
|
||||
|
||||
function authProfileRecoveryApplies(reason: FailoverReason): boolean {
|
||||
return (
|
||||
reason === "auth" ||
|
||||
reason === "auth_permanent" ||
|
||||
reason === "session_expired" ||
|
||||
reason === "billing" ||
|
||||
reason === "context_overflow" ||
|
||||
reason === "empty_response" ||
|
||||
reason === "no_error_details" ||
|
||||
reason === "unclassified" ||
|
||||
reason === "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export function renderAuthProfileFailoverCopy(params: AuthProfileFailureCopyParams): string {
|
||||
const description = params.allInCooldown
|
||||
? AUTH_PROFILE_COOLDOWN_COPY[params.reason](params.provider)
|
||||
: AUTH_PROFILE_DIRECT_COPY[params.reason]?.(params.provider);
|
||||
if (!description) {
|
||||
return params.causeText
|
||||
? params.causeText.trim() ||
|
||||
`Couldn't reach ${params.provider} with any of your saved logins right now.`
|
||||
: `Couldn't reach ${params.provider} with any of your saved logins right now.`;
|
||||
}
|
||||
const hint = authProfileRecoveryApplies(params.reason) ? params.recoveryHint : null;
|
||||
const causeText = params.causeText?.trim() ?? "";
|
||||
const suffix = causeText && !description.includes(causeText) ? ` (${causeText})` : "";
|
||||
return `${[description, hint].filter(Boolean).join(" ")}${suffix}`;
|
||||
}
|
||||
|
||||
const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal.";
|
||||
|
||||
export function renderControlUiAgentFailureCopy(errorText: string): string {
|
||||
return `⚠️ Agent failed before reply: ${errorText.trim().replace(/\.\s*$/, "")}.\n${CONTROL_UI_LOG_HINT}`;
|
||||
}
|
||||
|
||||
export function replaceGenericExternalRunFailureText(text: string): {
|
||||
text: string;
|
||||
replaced: boolean;
|
||||
} {
|
||||
if (text.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT) {
|
||||
return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, replaced: true };
|
||||
}
|
||||
const start = text.indexOf(GENERIC_EXTERNAL_RUN_FAILURE_TEXT);
|
||||
if (start < 0 || text.slice(start + GENERIC_EXTERNAL_RUN_FAILURE_TEXT.length).trim()) {
|
||||
return { text, replaced: false };
|
||||
}
|
||||
const prefix = text.slice(0, start).trimEnd();
|
||||
return {
|
||||
text: prefix
|
||||
? `${prefix} ${HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT}`
|
||||
: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
replaced: true,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { extractAssistantTextForPhase } from "../../shared/chat-message-content.js";
|
||||
import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js";
|
||||
import { sanitizeUserFacingText } from "../embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../embedded-agent-helpers/user-facing-text.js";
|
||||
|
||||
export function stripToolMessages(messages: unknown[]): unknown[] {
|
||||
return messages.filter((msg) => {
|
||||
@@ -47,5 +48,9 @@ export function extractAssistantText(message: unknown): string | undefined {
|
||||
// should not have its content rewritten with error templates (#13935).
|
||||
const errorContext = stopReason === "error";
|
||||
|
||||
return joined ? sanitizeUserFacingText(joined, { errorContext }) : undefined;
|
||||
return joined
|
||||
? errorContext
|
||||
? renderUserFacingText(joined, { errorContext: true })
|
||||
: sanitizeUserFacingText(joined)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
withTempHome,
|
||||
} from "../../test/helpers/auto-reply/trigger-handling-test-harness.js";
|
||||
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { renderControlUiAgentFailureCopy } from "../agents/failover/user-copy.js";
|
||||
import { resolveSessionKey } from "../config/sessions.js";
|
||||
import {
|
||||
loadExactSessionEntry,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js";
|
||||
import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js";
|
||||
import { buildControlUiAgentFailureText } from "./reply/agent-runner-failure-copy.js";
|
||||
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
@@ -65,7 +65,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
if (/context window exceeded/i.test(message)) {
|
||||
return "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.";
|
||||
}
|
||||
return buildControlUiAgentFailureText(message);
|
||||
return renderControlUiAgentFailureCopy(message);
|
||||
};
|
||||
const stripHeartbeat = (text?: string) => {
|
||||
const trimmed = text?.trim();
|
||||
@@ -361,7 +361,7 @@ describe("trigger handling", () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
error: "sandbox is not defined.",
|
||||
expected: buildControlUiAgentFailureText("sandbox is not defined."),
|
||||
expected: renderControlUiAgentFailureCopy("sandbox is not defined."),
|
||||
},
|
||||
{
|
||||
error: "Context window exceeded",
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
|
||||
import { classifyOAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
||||
import {
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
isCompactionFailureError,
|
||||
isLikelyContextOverflowError,
|
||||
isTransientHttpError,
|
||||
} from "../../agents/embedded-agent-helpers.js";
|
||||
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import { isFailoverError } from "../../agents/failover-error.js";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
renderBillingReplyCopy,
|
||||
renderControlUiAgentFailureCopy,
|
||||
renderRateLimitOrOverloadedCopy,
|
||||
renderRateLimitReplyCopy,
|
||||
} from "../../agents/failover/user-copy.js";
|
||||
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
|
||||
import {
|
||||
AGENT_RUN_RESTART_ABORT_STOP_REASON,
|
||||
@@ -23,19 +31,12 @@ import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.j
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
|
||||
import type { AgentTurnInternalResult, AgentTurnParams } from "./agent-runner-execution.types.js";
|
||||
import {
|
||||
buildControlUiAgentFailureText,
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "./agent-runner-failure-copy.js";
|
||||
import {
|
||||
buildAuthProfileFailoverFailureText,
|
||||
buildExternalRunFailureReply,
|
||||
buildRateLimitCooldownMessage,
|
||||
isNonDirectConversationContext,
|
||||
isVerboseFailureDetailEnabled,
|
||||
markAgentRunFailureReplyPayload,
|
||||
resolveBillingFailureReplyText,
|
||||
resolveExternalRunFailureTextForConversation,
|
||||
resolveReplyFailoverFacts,
|
||||
} from "./agent-runner-failure-reply.js";
|
||||
@@ -159,7 +160,7 @@ export async function handleAgentExecutionError(params: {
|
||||
);
|
||||
takePendingLifecycleTerminal()?.emit("error", err);
|
||||
const switchErrorText = params.shouldSurfaceToControlUi
|
||||
? buildControlUiAgentFailureText(
|
||||
? renderControlUiAgentFailureCopy(
|
||||
"model switch could not be completed. The requested model may be temporarily unavailable.",
|
||||
)
|
||||
: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel)
|
||||
@@ -424,13 +425,16 @@ export async function handleAgentExecutionError(params: {
|
||||
? isPureTransientSummary
|
||||
: failoverReason === "rate_limit" || failoverReason === "overloaded";
|
||||
const rateLimitOrOverloadedCopy =
|
||||
!hasFallbackAttempts || isPureTransientSummary
|
||||
? formatRateLimitOrOverloadedErrorCopy(
|
||||
isFailoverError(err) && failoverReason === "overloaded" ? "overloaded" : message,
|
||||
)
|
||||
(!hasFallbackAttempts &&
|
||||
(failoverReason === "rate_limit" || failoverReason === "overloaded")) ||
|
||||
isPureTransientSummary
|
||||
? renderRateLimitOrOverloadedCopy({
|
||||
reason: isOverloaded ? "overloaded" : "rate_limit",
|
||||
raw: message,
|
||||
})
|
||||
: undefined;
|
||||
const userFacingMessage = isTransientHttp
|
||||
? sanitizeUserFacingText(message, { errorContext: true })
|
||||
? renderUserFacingText(message, { errorContext: true })
|
||||
: message;
|
||||
const externalRunFailureReply =
|
||||
!isBilling &&
|
||||
@@ -450,15 +454,27 @@ export async function handleAgentExecutionError(params: {
|
||||
)
|
||||
: undefined;
|
||||
const fallbackText = isBilling
|
||||
? resolveBillingFailureReplyText(err)
|
||||
? renderBillingReplyCopy({
|
||||
attempts: fallbackAttempts,
|
||||
...(isFailoverError(err)
|
||||
? { provider: err.provider, model: err.model, authMode: err.authMode }
|
||||
: {}),
|
||||
})
|
||||
: isRateLimit && !isOverloaded
|
||||
? buildRateLimitCooldownMessage(err)
|
||||
? renderRateLimitReplyCopy({
|
||||
message,
|
||||
reason: failoverReason,
|
||||
attempts: fallbackAttempts,
|
||||
provider: isFailoverError(err) ? err.provider : undefined,
|
||||
cooldownExpiry: isFailoverError(err) ? err.soonestCooldownExpiry : undefined,
|
||||
sanitizeText: (text) => sanitizeUserFacingText(text, { errorContext: true }),
|
||||
})
|
||||
: rateLimitOrOverloadedCopy
|
||||
? rateLimitOrOverloadedCopy
|
||||
: isContextOverflow
|
||||
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
|
||||
: params.shouldSurfaceToControlUi
|
||||
? buildControlUiAgentFailureText(userFacingMessage)
|
||||
? renderControlUiAgentFailureCopy(userFacingMessage)
|
||||
: (externalRunFailureReply?.text ??
|
||||
(turn.isHeartbeat
|
||||
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import {
|
||||
setupAgentRunnerExecutionTestState,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
createMockTypingSignaler,
|
||||
createFollowupRun,
|
||||
} from "./agent-runner-execution.test-support.js";
|
||||
import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./agent-runner-failure-copy.js";
|
||||
|
||||
const state = setupAgentRunnerExecutionTestState();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FailoverError } from "../../agents/failover-error.js";
|
||||
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "../../agents/failover/user-copy.js";
|
||||
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveFallbackCandidateRun } from "./agent-runner-auth-profile.js";
|
||||
@@ -18,7 +19,6 @@ import type {
|
||||
FallbackRunnerParams,
|
||||
EmbeddedAgentParams,
|
||||
} from "./agent-runner-execution.test-support.js";
|
||||
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
|
||||
|
||||
const state = setupAgentRunnerExecutionTestState();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatBillingErrorMessage } from "../../agents/embedded-agent-helpers.js";
|
||||
import { FailoverError } from "../../agents/failover-error.js";
|
||||
import { BILLING_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js";
|
||||
import { ProviderAuthError } from "../../agents/model-auth.js";
|
||||
import { getReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
@@ -880,7 +881,7 @@ describe("executeAgentTurn: provider failures", () => {
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.text).toBe("billing");
|
||||
expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE);
|
||||
expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FailoverError } from "../../agents/failover-error.js";
|
||||
import {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "../../agents/failover/user-copy.js";
|
||||
import { AgentHarnessSessionSupersededError } from "../../agents/harness/errors.js";
|
||||
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
|
||||
import { getReplyPayloadMetadata } from "../reply-payload.js";
|
||||
@@ -19,7 +23,6 @@ import {
|
||||
createMinimalRunAgentTurnParams,
|
||||
createTestFallbackSummaryError,
|
||||
} from "./agent-runner-execution.test-support.js";
|
||||
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
|
||||
import { buildKnownAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js";
|
||||
|
||||
const state = setupAgentRunnerExecutionTestState();
|
||||
@@ -64,7 +67,7 @@ describe("executeAgentTurn: terminal failures", () => {
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.text).toBe("billing");
|
||||
expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE);
|
||||
expect(result.payload.text).not.toContain("All models failed");
|
||||
expect(result.payload.text).not.toContain("402 (billing)");
|
||||
expect(result.payload.text).not.toContain("Rate-limited");
|
||||
@@ -216,7 +219,7 @@ describe("executeAgentTurn: terminal failures", () => {
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.text).toBe("billing");
|
||||
expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Shared mocks and fixtures for agent-runner execution tests.
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
|
||||
import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/embedded-agent-helpers/error-text.js";
|
||||
import type { runEmbeddedAgentEntry } from "../../agents/embedded-agent-runner/run-entry.js";
|
||||
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
|
||||
import { FailoverError, type FallbackAttemptRecord } from "../../agents/failover-error.js";
|
||||
import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/failover/user-copy.js";
|
||||
import type { ModelDefinitionConfig } from "../../config/types.models.js";
|
||||
import {
|
||||
createUserTurnTranscriptRecorder,
|
||||
@@ -171,7 +171,6 @@ vi.mock("../../agents/embedded-agent-helpers.js", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
BILLING_ERROR_USER_MESSAGE: "billing",
|
||||
formatBillingErrorMessage: actual.formatBillingErrorMessage,
|
||||
isCompactionFailureError: (message?: string) => state.isCompactionFailureErrorMock(message),
|
||||
isContextOverflowError: (message?: string) => state.isContextOverflowErrorMock(message),
|
||||
|
||||
@@ -9,12 +9,13 @@ import type { ChatRunStartupPhase } from "../../../packages/gateway-protocol/src
|
||||
import { peekSessionMcpRuntime } from "../../agents/agent-bundle-mcp-manager-api.js";
|
||||
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
|
||||
import {
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
classifyFailoverReason,
|
||||
isContextOverflowError,
|
||||
} from "../../agents/embedded-agent-helpers.js";
|
||||
import type { EmbeddedAgentExecutionPhase } from "../../agents/embedded-agent-runner/execution-phase.js";
|
||||
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
|
||||
import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
|
||||
import { renderRateLimitOrOverloadedCopy } from "../../agents/failover/user-copy.js";
|
||||
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
|
||||
import { leaseMcpAppModelContextForTurn } from "../../agents/mcp-app-model-context.js";
|
||||
import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js";
|
||||
@@ -421,9 +422,11 @@ async function executeAgentTurnInternalWithRetryState(
|
||||
(p) => p.isError && hasNonEmptyString(p.text) && !p.text.startsWith("⚠️"),
|
||||
)?.text ?? "";
|
||||
const errorCandidate = metaErrorMsg || rawErrorPayloadText;
|
||||
const formattedErrorCandidate = errorCandidate
|
||||
? formatRateLimitOrOverloadedErrorCopy(errorCandidate)
|
||||
: undefined;
|
||||
const candidateReason = errorCandidate ? classifyFailoverReason(errorCandidate) : null;
|
||||
const formattedErrorCandidate =
|
||||
candidateReason === "rate_limit" || candidateReason === "overloaded"
|
||||
? renderRateLimitOrOverloadedCopy({ reason: candidateReason, raw: errorCandidate })
|
||||
: undefined;
|
||||
if (formattedErrorCandidate) {
|
||||
runResult.payloads = [
|
||||
markAgentRunFailureReplyPayload({
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Centralizes user-facing failure copy for external agent runner errors.
|
||||
import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/embedded-agent-helpers/error-text.js";
|
||||
|
||||
export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
|
||||
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
|
||||
|
||||
export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT =
|
||||
"⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available.";
|
||||
|
||||
export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session.";
|
||||
|
||||
export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again.";
|
||||
|
||||
export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening.";
|
||||
|
||||
export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`;
|
||||
|
||||
export const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE =
|
||||
"⚠️ The configured model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. This needs a config update (agents.defaults.model); retrying or starting a new session won't fix it.";
|
||||
|
||||
const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal.";
|
||||
|
||||
/** Preserves raw errors on internal surfaces while making the log command actionable. */
|
||||
export function buildControlUiAgentFailureText(errorText: string): string {
|
||||
const trimmedError = errorText.trim().replace(/\.\s*$/, "");
|
||||
return `⚠️ Agent failed before reply: ${trimmedError}.\n${CONTROL_UI_LOG_HINT}`;
|
||||
}
|
||||
|
||||
/** True when text is exactly the generic external run failure copy. */
|
||||
function isGenericExternalRunFailureText(text: string | undefined): boolean {
|
||||
return text?.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
|
||||
}
|
||||
|
||||
/** Replaces trailing generic failure text with heartbeat-specific copy. */
|
||||
export function replaceGenericExternalRunFailureText(text: string): {
|
||||
text: string;
|
||||
replaced: boolean;
|
||||
} {
|
||||
if (isGenericExternalRunFailureText(text)) {
|
||||
return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, replaced: true };
|
||||
}
|
||||
|
||||
const genericStart = text.indexOf(GENERIC_EXTERNAL_RUN_FAILURE_TEXT);
|
||||
if (genericStart < 0) {
|
||||
return { text, replaced: false };
|
||||
}
|
||||
|
||||
const trailing = text.slice(genericStart + GENERIC_EXTERNAL_RUN_FAILURE_TEXT.length).trim();
|
||||
if (trailing) {
|
||||
return { text, replaced: false };
|
||||
}
|
||||
|
||||
const prefix = text.slice(0, genericStart).trimEnd();
|
||||
return {
|
||||
text: prefix
|
||||
? `${prefix} ${HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT}`
|
||||
: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
replaced: true,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,34 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { formatAuthProfileFailureMessage } from "../../agents/auth-profiles/failure-copy.js";
|
||||
import {
|
||||
buildOAuthRefreshFailureLoginCommand,
|
||||
classifyOAuthRefreshFailureError,
|
||||
formatOAuthRefreshFailureLoginCommandMarkdown,
|
||||
} from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
||||
import {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
formatBillingErrorMessage,
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
} from "../../agents/embedded-agent-helpers.js";
|
||||
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import {
|
||||
describeFailoverError,
|
||||
findCliMaxTurnsError,
|
||||
findCliTimeoutError,
|
||||
isFailoverError,
|
||||
type FallbackAttemptRecord,
|
||||
} from "../../agents/failover-error.js";
|
||||
import { isPeriodicUsageLimitErrorMessage } from "../../agents/failover/classify.js";
|
||||
import { classifyProviderRequestFacets } from "../../agents/failover/request-error-facets.js";
|
||||
import {
|
||||
classifyProviderRequestFacets,
|
||||
type ProviderRequestFacet,
|
||||
} from "../../agents/failover/request-error-facets.js";
|
||||
import type { FailoverClassification } from "../../agents/failover/signal.js";
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
renderAuthProfileFailoverCopy,
|
||||
renderBillingReplyCopy,
|
||||
renderCliTimeoutReplyCopy,
|
||||
renderMissingApiKeyReplyCopy,
|
||||
renderRateLimitOrOverloadedCopy,
|
||||
renderRateLimitReplyCopy,
|
||||
resolveProviderRequestFailureCopy,
|
||||
type ReplyFallbackAttempt,
|
||||
} from "../../agents/failover/user-copy.js";
|
||||
import { isProviderAuthError } from "../../agents/model-auth-runtime-shared.js";
|
||||
import { buildProviderAuthRecoveryHint } from "../../agents/provider-auth-recovery-hint.js";
|
||||
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
@@ -35,18 +37,6 @@ import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE,
|
||||
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
|
||||
} from "./agent-runner-failure-copy.js";
|
||||
|
||||
const RATE_LIMIT_RETRY_MESSAGE =
|
||||
"⚠️ The model request was rate-limited. Please try again in a few minutes.";
|
||||
|
||||
export function resolveReplyFailoverFacts(error: unknown, message: string) {
|
||||
const described = describeFailoverError(error);
|
||||
@@ -55,7 +45,7 @@ export function resolveReplyFailoverFacts(error: unknown, message: string) {
|
||||
: null;
|
||||
return {
|
||||
reason: classification?.kind === "reason" ? classification.reason : undefined,
|
||||
providerRequestError: mapProviderRequestError({
|
||||
providerRequestError: resolveProviderRequestFailureCopy({
|
||||
classification,
|
||||
facet: classifyProviderRequestFacets({
|
||||
status: described.status,
|
||||
@@ -69,154 +59,9 @@ export function resolveReplyFailoverFacts(error: unknown, message: string) {
|
||||
|
||||
type ReplyFailoverFacts = ReturnType<typeof resolveReplyFailoverFacts>;
|
||||
|
||||
type ProviderRequestErrorCode =
|
||||
| "provider_authentication_error"
|
||||
| "provider_conversation_state_error"
|
||||
| "provider_internal_error"
|
||||
| "provider_model_unavailable"
|
||||
| "provider_rate_limit_or_quota_error";
|
||||
|
||||
function mapProviderRequestError(params: {
|
||||
classification: FailoverClassification | null;
|
||||
facet: ProviderRequestFacet | null;
|
||||
status?: number;
|
||||
technicalMessage: string;
|
||||
}) {
|
||||
const reason =
|
||||
params.classification?.kind === "reason" ? params.classification.reason : undefined;
|
||||
const mapped: [ProviderRequestErrorCode, string, true?] | undefined =
|
||||
reason === "auth" && params.status === 401
|
||||
? ["provider_authentication_error", PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE]
|
||||
: reason === "model_not_found"
|
||||
? ["provider_model_unavailable", PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE]
|
||||
: params.facet === "quota-429"
|
||||
? ["provider_rate_limit_or_quota_error", PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE]
|
||||
: params.facet === "conversation-state"
|
||||
? ["provider_conversation_state_error", PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE]
|
||||
: params.facet === "provider-internal" || params.facet === "provider-internal-503"
|
||||
? [
|
||||
"provider_internal_error",
|
||||
PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
params.facet === "provider-internal-503" || undefined,
|
||||
]
|
||||
: undefined;
|
||||
return mapped
|
||||
? {
|
||||
code: mapped[0],
|
||||
userMessage: mapped[1],
|
||||
technicalMessage: params.technicalMessage,
|
||||
...(mapped[2] ? { allowTransientHttpRetry: true as const } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Builds a human-friendly rate-limit message, including a known cooldown. */
|
||||
export function buildRateLimitCooldownMessage(err: unknown): string {
|
||||
const message = formatErrorMessage(err);
|
||||
const failoverFacts = resolveReplyFailoverFacts(err, message);
|
||||
const attempts = readFallbackAttempts(err);
|
||||
const codexUsageLimitMessage = extractCodexUsageLimitErrorMessage(
|
||||
attempts,
|
||||
message,
|
||||
failoverFacts.reason,
|
||||
isFailoverError(err) ? err.provider : undefined,
|
||||
);
|
||||
if (codexUsageLimitMessage) {
|
||||
return codexUsageLimitMessage;
|
||||
}
|
||||
if (attempts.some((attempt) => attempt.reason === "billing")) {
|
||||
return BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (failoverFacts.reason === "billing") {
|
||||
return BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
if (attempts.length === 0) {
|
||||
if (failoverFacts.reason === "rate_limit" && isPeriodicUsageLimitErrorMessage(message)) {
|
||||
const providerMessage = sanitizeUserFacingText(message, { errorContext: true });
|
||||
return providerMessage.startsWith("⚠️") ? providerMessage : `⚠️ ${providerMessage}`;
|
||||
}
|
||||
return RATE_LIMIT_RETRY_MESSAGE;
|
||||
}
|
||||
const expiry = isFailoverError(err) ? err.soonestCooldownExpiry : undefined;
|
||||
const now = Date.now();
|
||||
if (typeof expiry === "number" && expiry > now) {
|
||||
const secsLeft = Math.max(1, Math.ceil((expiry - now) / 1000));
|
||||
if (secsLeft <= 60) {
|
||||
return `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.`;
|
||||
}
|
||||
return `⚠️ Rate-limited — ready in ~${Math.ceil(secsLeft / 60)} min. Please try again shortly.`;
|
||||
}
|
||||
const attemptedModels = new Set(
|
||||
attempts.map((attempt) => `${attempt.provider}/${attempt.model}`),
|
||||
);
|
||||
if (
|
||||
attemptedModels.size > 1 &&
|
||||
attempts.every((attempt) => attempt.reason === "rate_limit" || attempt.reason === "overloaded")
|
||||
) {
|
||||
return "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes.";
|
||||
}
|
||||
return RATE_LIMIT_RETRY_MESSAGE;
|
||||
}
|
||||
|
||||
export function resolveBillingFailureReplyText(err: unknown): string {
|
||||
const attempts = readFallbackAttempts(err);
|
||||
const billingFailure =
|
||||
attempts.length > 0
|
||||
? attempts.find(
|
||||
(attempt) =>
|
||||
attempt.reason === "billing" &&
|
||||
(attempt.authMode === "oauth" || attempt.authMode === "token"),
|
||||
)
|
||||
: isFailoverError(err) && err.reason === "billing"
|
||||
? err
|
||||
: undefined;
|
||||
if (
|
||||
!billingFailure ||
|
||||
(billingFailure.authMode !== "oauth" && billingFailure.authMode !== "token")
|
||||
) {
|
||||
return BILLING_ERROR_USER_MESSAGE;
|
||||
}
|
||||
return formatBillingErrorMessage(
|
||||
billingFailure.provider,
|
||||
billingFailure.model,
|
||||
billingFailure.authMode,
|
||||
);
|
||||
}
|
||||
|
||||
function extractCodexUsageLimitErrorMessage(
|
||||
attempts: readonly ReplyFallbackAttempt[],
|
||||
directMessage: string,
|
||||
directReason: ReplyFailoverFacts["reason"],
|
||||
directProvider: string | undefined,
|
||||
): string | undefined {
|
||||
const attempt = attempts.find(
|
||||
(candidate) =>
|
||||
candidate.provider === "openai" && candidate.reason === "rate_limit" && candidate.error,
|
||||
);
|
||||
const text =
|
||||
attempt?.error ??
|
||||
(directProvider === "openai" && directReason === "rate_limit" ? directMessage : undefined);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const message = sanitizeUserFacingText(text, { errorContext: true })
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
const truncated = message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message;
|
||||
return truncated.startsWith("⚠️") ? truncated : `⚠️ ${truncated}`;
|
||||
}
|
||||
|
||||
type ReplyFallbackAttempt = FallbackAttemptRecord & { authMode?: string };
|
||||
|
||||
function readFallbackAttempts(err: unknown): readonly ReplyFallbackAttempt[] {
|
||||
return isFailoverError(err) && Array.isArray(err.attempts)
|
||||
? (err.attempts as readonly ReplyFallbackAttempt[])
|
||||
function readFallbackAttempts(error: unknown): readonly ReplyFallbackAttempt[] {
|
||||
return isFailoverError(error) && Array.isArray(error.attempts)
|
||||
? (error.attempts as readonly ReplyFallbackAttempt[])
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -231,7 +76,6 @@ function collapseRepeatedFailureDetail(message: string): string {
|
||||
return message.trim();
|
||||
}
|
||||
|
||||
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
|
||||
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
|
||||
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
|
||||
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";
|
||||
@@ -278,11 +122,6 @@ export function resolveExternalRunFailureTextForConversation(params: {
|
||||
return silentPolicy === "disallow" ? params.text : SILENT_REPLY_TOKEN;
|
||||
}
|
||||
|
||||
const CLI_BACKEND_NO_OUTPUT_STALL_RE =
|
||||
/\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu;
|
||||
const CLI_BACKEND_OVERALL_TIMEOUT_RE =
|
||||
/\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu;
|
||||
const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu;
|
||||
const CODEX_APP_SERVER_CLIENT_CLOSED_BEFORE_REPLY_RE =
|
||||
/\bcodex app-server client closed before turn completed\b/iu;
|
||||
const CODEX_APP_SERVER_TURN_COMPLETION_IDLE_TIMEOUT_RE =
|
||||
@@ -313,7 +152,7 @@ export function buildPreflightCompactionFailureText(
|
||||
if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const reason = sanitizeUserFacingText(
|
||||
const reason = renderUserFacingText(
|
||||
normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length),
|
||||
{ errorContext: true },
|
||||
)
|
||||
@@ -326,89 +165,21 @@ export function buildPreflightCompactionFailureText(
|
||||
);
|
||||
}
|
||||
|
||||
function buildCliBackendTimeoutFailureText(input: {
|
||||
message: string;
|
||||
error?: unknown;
|
||||
replayPrevented?: boolean;
|
||||
}): string | null {
|
||||
const normalizedMessage = collapseRepeatedFailureDetail(input.message);
|
||||
const cliTimeoutError = findCliTimeoutError(input.error);
|
||||
const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
|
||||
const overall = normalizedMessage.match(CLI_BACKEND_OVERALL_TIMEOUT_RE);
|
||||
const timeout = cliTimeoutError?.cliTimeout;
|
||||
const seconds = timeout?.timeoutSeconds ?? Number((stall ?? overall)?.[1]);
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
const routedModelRef = normalizedMessage.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1];
|
||||
const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : "";
|
||||
const mode = timeout?.mode ?? (stall ? "no-output" : "overall");
|
||||
let workStatus = "";
|
||||
const stoppedWork: string[] = [];
|
||||
if (timeout?.backgroundTaskCount) {
|
||||
const noun = timeout.backgroundTaskCount === 1 ? "task" : "tasks";
|
||||
stoppedWork.push(`${timeout.backgroundTaskCount} CLI background ${noun}`);
|
||||
}
|
||||
if (timeout?.activeToolCount) {
|
||||
const noun = timeout.activeToolCount === 1 ? "call" : "calls";
|
||||
stoppedWork.push(`${timeout.activeToolCount} active CLI tool ${noun}`);
|
||||
}
|
||||
if (stoppedWork.length > 0) {
|
||||
workStatus = ` It also stopped ${stoppedWork.join(" and ")}; that work shares the parent CLI process. Effects may be partial; check before retrying.`;
|
||||
} else if (timeout?.observedActivity) {
|
||||
workStatus =
|
||||
" The CLI had already begun work, so effects may be partial; check before retrying.";
|
||||
}
|
||||
if (input.replayPrevented) {
|
||||
workStatus += " OpenClaw did not replay this turn automatically.";
|
||||
}
|
||||
if (mode === "no-output") {
|
||||
const backendId = cliTimeoutError?.provider ?? "<id>";
|
||||
return (
|
||||
`⚠️ CLI subprocess${routingSuffix}: no output for ${seconds}s, so the no-output watchdog stopped it. ` +
|
||||
`This is separate from the overall agent timeout; the gateway is unaffected.${workStatus} ` +
|
||||
"Check for an interactive prompt. " +
|
||||
`The CLI backend ${backendId} produced no output before its watchdog expired.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`⚠️ CLI turn${routingSuffix}: timed out after ${seconds}s (overall turn limit). The gateway is unaffected.${workStatus} ` +
|
||||
"For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise `agents.defaults.timeoutSeconds`."
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingApiKeyFailureText(input: { message: string; error?: unknown }): string | null {
|
||||
const authError = isProviderAuthError(input.error) ? input.error : undefined;
|
||||
const provider = authError?.provider.trim().toLowerCase();
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
if (provider === "openai" && authError?.providerGuidance) {
|
||||
return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-sol` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.";
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.';
|
||||
}
|
||||
if (SAFE_MISSING_API_KEY_PROVIDERS.has(provider)) {
|
||||
return `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`;
|
||||
}
|
||||
return "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.";
|
||||
}
|
||||
|
||||
export function buildAuthProfileFailoverFailureText(error: unknown): string | null {
|
||||
if (!isFailoverError(error) || !error.provider || !error.authProfileFailure) {
|
||||
return null;
|
||||
}
|
||||
return formatAuthProfileFailureMessage({
|
||||
return renderAuthProfileFailoverCopy({
|
||||
reason: error.reason,
|
||||
provider: error.provider,
|
||||
allInCooldown: error.authProfileFailure.allInCooldown,
|
||||
cause: error.cause,
|
||||
causeText: error.cause ? formatErrorMessage(error.cause).trim() : undefined,
|
||||
recoveryHint: buildProviderAuthRecoveryHint({ provider: error.provider }),
|
||||
});
|
||||
}
|
||||
|
||||
function formatForwardedExternalRunFailureText(message: string): string {
|
||||
const sanitized = sanitizeUserFacingText(message, { errorContext: true })
|
||||
const sanitized = renderUserFacingText(message, { errorContext: true })
|
||||
.trim()
|
||||
.replace(/^⚠️\s*/u, "")
|
||||
.replace(/\s+/gu, " ");
|
||||
@@ -478,13 +249,15 @@ export function buildExternalRunFailureReply(
|
||||
const cliMaxTurnsError = findCliMaxTurnsError(error);
|
||||
if (cliMaxTurnsError) {
|
||||
return {
|
||||
text: sanitizeUserFacingText(cliMaxTurnsError.message, { errorContext: true }),
|
||||
text: renderUserFacingText(cliMaxTurnsError.message, { errorContext: true }),
|
||||
isGenericRunnerFailure: false,
|
||||
};
|
||||
}
|
||||
const cliBackendTimeoutFailure = buildCliBackendTimeoutFailureText({
|
||||
const cliTimeoutError = findCliTimeoutError(error);
|
||||
const cliBackendTimeoutFailure = renderCliTimeoutReplyCopy({
|
||||
message: normalizedMessage,
|
||||
error,
|
||||
cliTimeout: cliTimeoutError?.cliTimeout,
|
||||
provider: cliTimeoutError?.provider,
|
||||
replayPrevented: options?.replayPrevented,
|
||||
});
|
||||
if (cliBackendTimeoutFailure) {
|
||||
@@ -494,10 +267,12 @@ export function buildExternalRunFailureReply(
|
||||
if (providerRequestError) {
|
||||
return { text: providerRequestError.userMessage, isGenericRunnerFailure: false };
|
||||
}
|
||||
const missingApiKeyFailure = buildMissingApiKeyFailureText({
|
||||
message: normalizedMessage,
|
||||
error,
|
||||
});
|
||||
const authError = isProviderAuthError(error) ? error : undefined;
|
||||
const missingApiKeyFailure = renderMissingApiKeyReplyCopy(
|
||||
authError
|
||||
? { provider: authError.provider, providerGuidance: authError.providerGuidance }
|
||||
: undefined,
|
||||
);
|
||||
if (missingApiKeyFailure) {
|
||||
return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
|
||||
}
|
||||
@@ -598,7 +373,16 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
|
||||
if (isBilling) {
|
||||
return markAgentRunFailureReplyPayload({
|
||||
text: resolveExternalRunFailureTextForConversation({
|
||||
text: resolveBillingFailureReplyText(params.err),
|
||||
text: renderBillingReplyCopy({
|
||||
attempts: fallbackAttempts,
|
||||
...(isFailoverError(params.err)
|
||||
? {
|
||||
provider: params.err.provider,
|
||||
model: params.err.model,
|
||||
authMode: params.err.authMode,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: false,
|
||||
cfg: params.cfg,
|
||||
@@ -633,16 +417,28 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
|
||||
? isPureTransientSummary
|
||||
: failoverReason === "rate_limit" || failoverReason === "overloaded";
|
||||
const rateLimitOrOverloadedCopy =
|
||||
!hasFallbackAttempts || isPureTransientSummary
|
||||
? formatRateLimitOrOverloadedErrorCopy(
|
||||
isFailoverError(params.err) && failoverReason === "overloaded" ? "overloaded" : message,
|
||||
)
|
||||
(!hasFallbackAttempts &&
|
||||
(failoverReason === "rate_limit" || failoverReason === "overloaded")) ||
|
||||
isPureTransientSummary
|
||||
? renderRateLimitOrOverloadedCopy({
|
||||
reason: isOverloaded ? "overloaded" : "rate_limit",
|
||||
raw: message,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isRateLimit && !isOverloaded) {
|
||||
return markAgentRunFailureReplyPayload({
|
||||
text: resolveExternalRunFailureTextForConversation({
|
||||
text: buildRateLimitCooldownMessage(params.err),
|
||||
text: renderRateLimitReplyCopy({
|
||||
message,
|
||||
reason: failoverReason,
|
||||
attempts: fallbackAttempts,
|
||||
provider: isFailoverError(params.err) ? params.err.provider : undefined,
|
||||
cooldownExpiry: isFailoverError(params.err)
|
||||
? params.err.soonestCooldownExpiry
|
||||
: undefined,
|
||||
sanitizeText: (text) => sanitizeUserFacingText(text, { errorContext: true }),
|
||||
}),
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: false,
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isContextOverflowError } from "../../agents/embedded-agent-helpers.js";
|
||||
import {
|
||||
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
renderControlUiAgentFailureCopy,
|
||||
} from "../../agents/failover/user-copy.js";
|
||||
import {
|
||||
createAgentRunRestartAbortError,
|
||||
isAgentRunRestartAbortReason,
|
||||
@@ -9,10 +13,6 @@ import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
|
||||
import {
|
||||
buildControlUiAgentFailureText,
|
||||
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
} from "./agent-runner-failure-copy.js";
|
||||
import { markAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js";
|
||||
import type { AgentFallbackCandidatesResult } from "./agent-runner-fallback-candidate.js";
|
||||
import type {
|
||||
@@ -119,7 +119,7 @@ export async function settleAgentFallbackCycle(params: {
|
||||
kind: "final",
|
||||
payload: markAgentRunFailureReplyPayload({
|
||||
text: cycle.shouldSurfaceToControlUi
|
||||
? buildControlUiAgentFailureText(embeddedErrorText)
|
||||
? renderControlUiAgentFailureCopy(embeddedErrorText)
|
||||
: PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -125,7 +126,9 @@ function sanitizeFinalReplyText(
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
|
||||
return payload.isError
|
||||
? renderUserFacingText(text, { errorContext: true })
|
||||
: sanitizeUserFacingText(text);
|
||||
}
|
||||
|
||||
function sanitizeHeartbeatPayload(payload: ReplyPayload): ReplyPayload {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import {
|
||||
@@ -79,7 +80,9 @@ export function createAgentTurnPresentation(params: {
|
||||
if (!text) {
|
||||
return { skip: true };
|
||||
}
|
||||
const sanitized = sanitizeUserFacingText(text, { errorContext });
|
||||
const sanitized = errorContext
|
||||
? renderUserFacingText(text, { errorContext: true })
|
||||
: sanitizeUserFacingText(text);
|
||||
return sanitized.trim() ? { text: sanitized, skip: false } : { skip: true };
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { join } from "node:path";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "../../agents/failover/user-copy.js";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import {
|
||||
@@ -21,10 +25,6 @@ import {
|
||||
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
|
||||
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "./agent-runner-failure-copy.js";
|
||||
import type { InternalGetReplyOptions } from "./get-reply.types.js";
|
||||
import {
|
||||
clearSessionQueues,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// 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 { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js";
|
||||
import { setReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./agent-runner-failure-copy.js";
|
||||
import {
|
||||
NO_VISIBLE_REPLY_FALLBACK_TEXT,
|
||||
QUEUE_CAP_REJECTION_TEXT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Normalizes raw agent output into sendable reply text and metadata.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { copyReplyPayloadMetadata } from "../reply-payload.js";
|
||||
@@ -102,7 +103,9 @@ export function normalizeReplyPayload(
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
|
||||
text = payload.isError
|
||||
? renderUserFacingText(text, { errorContext: true })
|
||||
: sanitizeUserFacingText(text);
|
||||
}
|
||||
if (!hasContent(text)) {
|
||||
opts.onSkip?.("empty");
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
classifyAgentRunTerminalOutcome,
|
||||
type AgentRunTerminalOutcome,
|
||||
} from "../agents/agent-run-terminal-outcome.js";
|
||||
import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import {
|
||||
isMainSessionRecoveryLifecycleEvent,
|
||||
projectMainSessionRecoveryLifecycle,
|
||||
@@ -96,7 +96,7 @@ function resolveSessionRunError(
|
||||
if ((status !== "failed" && status !== "timeout") || !outcome.error) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = sanitizeUserFacingText(outcome.error, { errorContext: true })
|
||||
const sanitized = renderUserFacingText(outcome.error, { errorContext: true })
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return sanitized ? truncateUtf16Safe(sanitized, SESSION_RUN_ERROR_MAX_CHARS) : undefined;
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
hasOutboundReplyContent,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { replaceGenericExternalRunFailureText } from "../agents/failover/user-copy.js";
|
||||
import { copyReplyPayloadMetadata, getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
|
||||
import { replaceGenericExternalRunFailureText } from "../auto-reply/reply/agent-runner-failure-copy.js";
|
||||
import { buildRecoverablePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js";
|
||||
import { sendDurableMessageBatch } from "../channels/message/runtime.js";
|
||||
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Covers heartbeat tool-response handling and visible reply policy.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "../agents/failover/user-copy.js";
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import {
|
||||
createHeartbeatToolResponsePayload,
|
||||
@@ -9,10 +13,6 @@ import {
|
||||
markReplyPayloadForSourceSuppressionDelivery,
|
||||
setReplyPayloadMetadata,
|
||||
} from "../auto-reply/reply-payload.js";
|
||||
import {
|
||||
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
||||
} from "../auto-reply/reply/agent-runner-failure-copy.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
|
||||
@@ -122,6 +122,8 @@ export type QaBusMessage = {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
text: string;
|
||||
/** Runtime-authored failure marker; copy wording is not a QA contract. */
|
||||
isError?: boolean;
|
||||
timestamp: number;
|
||||
threadId?: string;
|
||||
threadTitle?: string;
|
||||
@@ -187,6 +189,8 @@ export type QaBusOutboundMessageInput = {
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
text: string;
|
||||
/** Preserves ReplyPayload.isError through the synthetic channel transport. */
|
||||
isError?: boolean;
|
||||
timestamp?: number;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type SessionAgentAttentionIconId,
|
||||
type SessionAgentStatus,
|
||||
} from "../../packages/gateway-protocol/src/session-agent-status.js";
|
||||
import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
|
||||
const SESSION_AGENT_STATUS_NOTE_MAX_CHARS = 120;
|
||||
const SESSION_AGENT_STATUS_DEFAULT_TTL_MINUTES = 30;
|
||||
@@ -19,7 +19,7 @@ export function isSessionAgentAttentionIconId(
|
||||
}
|
||||
|
||||
export function sanitizeSessionAgentStatusNote(value: string): string {
|
||||
const normalized = sanitizeUserFacingText(value, { errorContext: true })
|
||||
const normalized = renderUserFacingText(value, { errorContext: true })
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return truncateUtf16Safe(normalized, SESSION_AGENT_STATUS_NOTE_MAX_CHARS).trimEnd();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Builds task status summaries and formatted status text for user-facing surfaces.
|
||||
import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js";
|
||||
import {
|
||||
INTERNAL_RUNTIME_CONTEXT_BEGIN,
|
||||
INTERNAL_RUNTIME_CONTEXT_END,
|
||||
@@ -72,7 +73,7 @@ function stripInlineLeakedInternalContext(value: string): string {
|
||||
|
||||
function sanitizeTaskStatusValue(value: unknown, errorContext: boolean): unknown {
|
||||
if (typeof value === "string") {
|
||||
const sanitized = sanitizeUserFacingText(stripInlineLeakedInternalContext(value), {
|
||||
const sanitized = renderUserFacingText(stripInlineLeakedInternalContext(value), {
|
||||
errorContext,
|
||||
})
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
Reference in New Issue
Block a user