惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Security Blog
Microsoft Security Blog
量子位
大猫的无限游戏
大猫的无限游戏
酷 壳 – CoolShell
酷 壳 – CoolShell
IT之家
IT之家
博客园 - 三生石上(FineUI控件)
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - Franky
美团技术团队
Last Week in AI
Last Week in AI
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
人人都是产品经理
人人都是产品经理
罗磊的独立博客
Jina AI
Jina AI
小众软件
小众软件
S
SegmentFault 最新的问题
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
雷峰网
雷峰网
博客园 - 聂微东
博客园_首页
The Cloudflare Blog
WordPress大学
WordPress大学
Apple Machine Learning Research
Apple Machine Learning Research
有赞技术团队
有赞技术团队

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
fix(runner): surface provider errors to webchat (#70848) ...
truffle-dev · 2026-04-24 · via Recent Commits to openclaw:main

@@ -0,0 +1,232 @@

1+

import { describe, expect, it, vi } from "vitest";

2+

import { FailoverError } from "../../failover-error.js";

3+

import { formatBillingErrorMessage } from "../../pi-embedded-helpers.js";

4+

import { handleAssistantFailover } from "./assistant-failover.js";

5+6+

type Params = Parameters<typeof handleAssistantFailover>[0];

7+

type Outcome = Awaited<ReturnType<typeof handleAssistantFailover>>;

8+9+

function makeParams(overrides: Partial<Params> = {}): Params {

10+

const provider = "Anthropic";

11+

const model = "claude-haiku-4-5-20251001";

12+

const defaults: Params = {

13+

initialDecision: { action: "surface_error", reason: "billing" },

14+

aborted: false,

15+

externalAbort: false,

16+

fallbackConfigured: false,

17+

failoverFailure: true,

18+

failoverReason: "billing",

19+

timedOut: false,

20+

idleTimedOut: false,

21+

timedOutDuringCompaction: false,

22+

allowSameModelIdleTimeoutRetry: false,

23+

assistantProfileFailureReason: null,

24+

lastProfileId: undefined,

25+

modelId: model,

26+

provider,

27+

activeErrorContext: { provider, model },

28+

lastAssistant: undefined,

29+

config: undefined,

30+

sessionKey: undefined,

31+

authFailure: false,

32+

rateLimitFailure: false,

33+

billingFailure: true,

34+

cloudCodeAssistFormatError: false,

35+

isProbeSession: false,

36+

overloadProfileRotations: 0,

37+

overloadProfileRotationLimit: 3,

38+

previousRetryFailoverReason: null,

39+

logAssistantFailoverDecision: vi.fn(),

40+

warn: vi.fn(),

41+

maybeMarkAuthProfileFailure: vi.fn(async () => {}),

42+

maybeEscalateRateLimitProfileFallback: vi.fn(),

43+

maybeBackoffBeforeOverloadFailover: vi.fn(async () => {}),

44+

advanceAuthProfile: vi.fn(async () => false),

45+

};

46+

return { ...defaults, ...overrides };

47+

}

48+49+

function expectThrownFailoverError(outcome: Outcome): FailoverError {

50+

expect(outcome.action).toBe("throw");

51+

if (outcome.action !== "throw") {

52+

throw new Error("expected throw outcome");

53+

}

54+

expect(outcome.error).toBeInstanceOf(FailoverError);

55+

return outcome.error;

56+

}

57+58+

describe("handleAssistantFailover", () => {

59+

describe("surface_error branch (openclaw#70124)", () => {

60+

it("throws a billing FailoverError so the webchat can render the provider failure", async () => {

61+

const logDecision = vi.fn();

62+

const outcome = await handleAssistantFailover(

63+

makeParams({

64+

initialDecision: { action: "surface_error", reason: "billing" },

65+

failoverReason: "billing",

66+

billingFailure: true,

67+

logAssistantFailoverDecision: logDecision,

68+

}),

69+

);

70+71+

const err = expectThrownFailoverError(outcome);

72+

expect(err.reason).toBe("billing");

73+

expect(err.message).toBe(formatBillingErrorMessage("Anthropic", "claude-haiku-4-5-20251001"));

74+

expect(err.status).toBe(402);

75+

expect(err.provider).toBe("Anthropic");

76+

expect(err.model).toBe("claude-haiku-4-5-20251001");

77+

expect(logDecision).toHaveBeenCalledWith("surface_error");

78+

});

79+80+

it("throws an auth FailoverError for auth-classified surface errors", async () => {

81+

const outcome = await handleAssistantFailover(

82+

makeParams({

83+

initialDecision: { action: "surface_error", reason: "auth" },

84+

failoverReason: "auth",

85+

billingFailure: false,

86+

authFailure: true,

87+

}),

88+

);

89+90+

const err = expectThrownFailoverError(outcome);

91+

expect(err.reason).toBe("auth");

92+

expect(err.message).toBe("LLM request unauthorized.");

93+

expect(err.status).toBe(401);

94+

});

95+96+

it("throws a rate_limit FailoverError for rate-limited surface errors", async () => {

97+

const outcome = await handleAssistantFailover(

98+

makeParams({

99+

initialDecision: { action: "surface_error", reason: "rate_limit" },

100+

failoverReason: "rate_limit",

101+

billingFailure: false,

102+

rateLimitFailure: true,

103+

}),

104+

);

105+106+

const err = expectThrownFailoverError(outcome);

107+

expect(err.reason).toBe("rate_limit");

108+

expect(err.message).toBe("LLM request rate limited.");

109+

expect(err.status).toBe(429);

110+

});

111+112+

it("preserves the raw provider error on surfaced failures", async () => {

113+

const rawError = ' 400 {"error":{"message":"credit balance is too low"}} ';

114+

const outcome = await handleAssistantFailover(

115+

makeParams({

116+

initialDecision: { action: "surface_error", reason: "billing" },

117+

failoverReason: "billing",

118+

billingFailure: true,

119+

lastAssistant: {

120+

errorMessage: rawError,

121+

model: "claude-haiku-4-5-20251001",

122+

provider: "Anthropic",

123+

} as Params["lastAssistant"],

124+

}),

125+

);

126+127+

const err = expectThrownFailoverError(outcome);

128+

expect(err.reason).toBe("billing");

129+

expect(err.rawError).toBe(rawError.trim());

130+

});

131+132+

it("coerces a null decision reason onto the most specific non-timeout failure signal", async () => {

133+

// failover-policy can return `surface_error` with `reason: null`

134+

// when shouldRotateAssistant fires on `failoverFailure` without a

135+

// classified upstream reason. FailoverError requires a concrete

136+

// reason, so the throw path coerces null onto the most specific

137+

// signal the run observed.

138+

const outcome = await handleAssistantFailover(

139+

makeParams({

140+

initialDecision: { action: "surface_error", reason: null },

141+

failoverReason: null,

142+

timedOut: false,

143+

billingFailure: false,

144+

authFailure: true,

145+

}),

146+

);

147+148+

const err = expectThrownFailoverError(outcome);

149+

expect(err.reason).toBe("auth");

150+

expect(err.message).toBe("LLM request unauthorized.");

151+

expect(err.status).toBe(401);

152+

});

153+154+

it("leaves externally-aborted runs on the continue_normal path", async () => {

155+

// External aborts (user pressed stop) must never synthesize a

156+

// provider error; the partial assistant output carries the turn.

157+

const outcome = await handleAssistantFailover(

158+

makeParams({

159+

initialDecision: { action: "surface_error", reason: null },

160+

externalAbort: true,

161+

aborted: true,

162+

failoverReason: null,

163+

billingFailure: false,

164+

}),

165+

);

166+167+

expect(outcome.action).toBe("continue_normal");

168+

});

169+170+

it("leaves plain timeouts on the continue_normal path for the runner's timeout-payload synthesis", async () => {

171+

// `run.ts` already emits an explicit timeout payload when

172+

// `buildEmbeddedRunPayloads` produces no assistant content (see

173+

// the `timedOut && !timedOutDuringCompaction &&

174+

// !payloadsWithToolMedia.length` block). Throwing a FailoverError

175+

// here would short-circuit that synthesis and break

176+

// timeout-compaction retry coverage in

177+

// `run.timeout-triggered-compaction.test.ts`. The throw path is

178+

// reserved for concrete provider failures that have no other

179+

// downstream surface.

180+

const outcome = await handleAssistantFailover(

181+

makeParams({

182+

initialDecision: { action: "surface_error", reason: null },

183+

failoverReason: null,

184+

timedOut: true,

185+

billingFailure: false,

186+

}),

187+

);

188+189+

expect(outcome.action).toBe("continue_normal");

190+

});

191+192+

it("retries the same model when an idle-timeout retry is allowed", async () => {

193+

const outcome = await handleAssistantFailover(

194+

makeParams({

195+

initialDecision: { action: "surface_error", reason: null },

196+

failoverReason: null,

197+

timedOut: true,

198+

idleTimedOut: true,

199+

allowSameModelIdleTimeoutRetry: true,

200+

billingFailure: false,

201+

}),

202+

);

203+204+

expect(outcome.action).toBe("retry");

205+

if (outcome.action !== "retry") {

206+

return;

207+

}

208+

expect(outcome.retryKind).toBe("same_model_idle_timeout");

209+

});

210+

});

211+212+

describe("fallback_model branch", () => {

213+

it("still throws a FailoverError after the surface_error refactor", async () => {

214+

const logDecision = vi.fn();

215+

const outcome = await handleAssistantFailover(

216+

makeParams({

217+

initialDecision: { action: "fallback_model", reason: "billing" },

218+

fallbackConfigured: true,

219+

failoverReason: "billing",

220+

billingFailure: true,

221+

logAssistantFailoverDecision: logDecision,

222+

}),

223+

);

224+225+

const err = expectThrownFailoverError(outcome);

226+

expect(err.reason).toBe("billing");

227+

expect(err.status).toBe(402);

228+

expect(err.message).toBe(formatBillingErrorMessage("Anthropic", "claude-haiku-4-5-20251001"));

229+

expect(logDecision).toHaveBeenCalledWith("fallback_model", { status: 402 });

230+

});

231+

});

232+

});