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:
Peter Steinberger
2026-08-10 16:43:51 -07:00
committed by GitHub
parent 96b0cf95ef
commit 6dc77a37d9
56 changed files with 1362 additions and 1262 deletions
+17 -17
View File
@@ -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"}
+1
View File
@@ -190,6 +190,7 @@ export async function sendQaBusMessage(params: {
accountId: string;
to: string;
text: string;
isError?: boolean;
senderId?: string;
senderName?: string;
threadId?: string;
+9 -1
View File
@@ -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({
+2
View File
@@ -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 };
+18 -4
View File
@@ -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) => {
+2
View File
@@ -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,
+3
View File
@@ -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({
+3 -5
View File
@@ -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);
}
+16 -33
View File
@@ -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");
});
});
+6 -21
View File
@@ -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);
}
+16 -13
View File
@@ -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);
});
-137
View File
@@ -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 },
),
+1 -2
View File
@@ -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,
+18 -16
View File
@@ -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 =
+4 -1
View File
@@ -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.",
);
});
+97
View File
@@ -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`.",
);
});
});
+697
View File
@@ -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 -1
View File
@@ -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,
+4 -1
View File
@@ -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");
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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";
+4
View File
@@ -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;
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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, " ")