feat: AI 调用稳定性增强(可重试错误分类 + 指数退避 + 修复链路错误透传)

- providers/base.ts 引入 ApiRequestError 与 isRetryableError,按 429/5xx/超时/网络错误/401 分类
- chatWithRetry 升级为最多 3 次指数退避(1s/2s/4s),非重试错误立即上抛;新增 setRetryBaseDelayForTest 钩子
- aiFixEngine/customFixEngine 不再吞错,修复失败原因透传为 ai-error: <原因>
- 扩展 ai-empty-response/customFixEngine 测试覆盖重试与分类
This commit is contained in:
范智鹏
2026-08-31 21:57:54 +08:00
parent 458647d82e
commit d6b8a3c897
11 changed files with 287 additions and 55 deletions
+3 -2
View File
@@ -1,4 +1,4 @@
# AI Usage Log
# AI Usage Log
| 日期时间 | 范式步骤 | 修改摘要 | 中间产物 | 涉及文件 | 使用模型 |
|---------|---------|---------|---------|---------|---------|
| 2026-07-10 18:10 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 编写代码审查插件设计文档(design spec | design spec | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
@@ -237,4 +237,5 @@
| 2026-08-27 22:32 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 生成 tests/coverage/ 测试覆盖率报告(成果物 04 缺失项):.vscode-test.mjs 加入 srcDir:src 与 coverage 配置(reporter text-summary+htmlinclude out/src/**/*.jsexclude out/tests 与 out/src/types,仅统计测试触达模块);package.json 新增脚本 test:coveragecompile → compile:test → vscode-test --coverage --coverage-output tests/coverage)。实测 116 passing,覆盖率:行/语句 42.58%4915/11541)、函数 34.08%、分支 73.37%,产出 tests/coverage/index.html + src 逐文件页(2.1MB),未被 gitignore 忽略可入库。验证:复跑两次结果一致(可复现),报告路径与文件齐全 | 中间产物:①reporter 初稿含 lcov,实测该版本 test-cli 未产出 lcov.info(仅 html/text-summary 生效),收敛为 ['text-summary','html'] 后复跑确认一致;②coverage 统计口径先考虑 includeAll:true(全量含 0% 模块),与用户确认后采用仅测试触达模块(默认 false);③vscode-test 首次运行自动下载 VS Code 1.135.0319MB 缓存于 .vscode-test/gitignore 已排除),二次复跑走缓存 | .vscode-test.mjs(更新) package.json(更新) tests/coverage/**(新建) | deepseek-v4-flash |
| 2026-08-27 22:49 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 覆盖率补测扩展项(试点 3 项 + 扩展 5 项共 8 个新测试文件、63 条用例):tests/fix-prompt.test.ts(10)、registry.test.ts(7)、provider-chat.test.ts(11mock global.fetch 测 OpenAI 兼容/Gemini/Claude 三协议 chat)、import-service.test.ts(7)、yaml-parser.test.ts(3)。其中 export-service 因依赖 vscode.window.showSaveDialogUI 对话框无法在测试宿主 stub)替换为 yaml-parser(纯逻辑)。同步修复试点发现的 2 处源码缺陷:method-extractor fallback java 正则取错捕获组(match[1]→match[2] ?? match[1])、mockDocument.getText 忽略 range 参数(改为按 range 切片)。实测 179 passing / 行覆盖率 42.58%→48.24%+5.66,分支 77.97%、函数 43.66%),覆盖率报告刷新 tests/coverage/;更新 tests/test-cases.md16 文件 116 条 → 24 文件 179 条)与 tests/test-execution-log.md(新增覆盖率执行记录段)。验证:npm run test:coverage 全绿(Exit 0 | 中间产物:①扩展清单原含 export-service,读源码确认依赖 showSaveDialog 无法 stub,改用 yaml-parser 并在日志注明;②import-service 断言初稿断言 duplicateOf 不出现在输出,实际注释掉的行仍含原文,改为断言重复元数据仅以 # 注释形式出现;③fix-prompt 边界测试初稿断言末行为 3| c,实际代码含尾部空行,改为断言整个输出含 3| c;④mockDocument 重构为命名函数 offsetAt/positionAt 供 getText(range) 复用(对象内无法互相引用属性) | tests/jsp-extractor.test.ts(新建) tests/report.test.ts(新建) tests/method-extractor.test.ts(新建) tests/fix-prompt.test.ts(新建) tests/registry.test.ts(新建) tests/provider-chat.test.ts(新建) tests/import-service.test.ts(新建) tests/yaml-parser.test.ts(新建) src/scope/method-extractor.ts(修复) src/utils/mockDocument.ts(修复) tests/coverage/**(刷新) tests/test-cases.md(更新) tests/test-execution-log.md(更新) | deepseek-v4-flash |
| 2026-08-29 11:03 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复审查面板「重新审核」按钮失效:面板聚焦时 activeTextEditor 为空导致报「请先打开一个文件」。webview handleMessage 的 case 'rerun' 改为携带 currentReport.filePathcodeReviewer.review 命令改为按 activeTextEditor.document → workspace.textDocuments 匹配 fsPath → openTextDocument 三级解析文档,不再依赖编辑器焦点,始终审核报告所属文件。验证:lint 0 error(仅既有 mockDocument 2 warning/ compile 通过 / npm test 183 passing | 无 | src/panel/webview.ts src/activation/commands.ts | deepseek-v4-flash |
| 2026-08-29 11:23 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 评审整改 P0 文档包(针对第 12 次评审 79 分三大失分项):新建 measurement/ 测量协议入口(baseline.md 人工基线报告 + timing-log.md 计时日志与可重演命令,命中评审探针「measurement 目录/baseline.md/timing-log/可重演命令」四要素,数值全部从 tests/measure/results/*.json 真实转录);新建 docs/demo-storyboard.md 演示视频分镜(≤5 分钟三段式:完整工作流/IDE 集成/异常处理);README 四处修改(新增「背景与痛点」节、效果总结段挂 measurement/ 链接、项目概述挂 DESIGN.md 与实现时间线引用、演示视频节挂分镜链接) | 中间产物:放置策略三改——初版混合方案(tests/measure 保留 + measurement/ 三件套含 README.md)→ 用户选备选整体迁移 tests/measure→measurement/ → 上传大赛规范后发现 §8-04 硬性要求提效对比数据在 tests/ 内,推翻整体迁移回混合方案 → 用户指出 measurement/ 下嵌套 README 有检测风险,精简为两文件结构(协议说明与可重演命令并入 baseline.md/timing-log.md 正文) | measurement/baseline.md(新建) measurement/timing-log.md(新建) docs/demo-storyboard.md(新建) README.md(修改) | TRAE |
| 2026-08-29 11:23 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码 实现 | 评审整改 P0 文档包(针对第 12 次评审 79 分三大失分项):新建 measurement/ 测量协议入口(baseline.md 人工基线报告 + timing-log.md 计时日志与可重演命令,命中评审探针「measurement 目录/baseline.md/timing-log/可重演命令」四要素,数值全部从 tests/measure/results/*.json 真实转录);新建 docs/demo-storyboard.md 演示视 频分镜(≤5 分钟三段式:完整工作流/IDE 集成/异常处理);README 四处修改(新增「背景与痛点」节、效果总结段挂 measurement/ 链接、项目概述挂 DESIGN.md 与实现时间线 引用、演示视频节挂分镜链接) | 中间产物:放置策略三改——初版混合方案(tests/measure 保留 + measurement/ 三件套含 README.md)→ 用户选备选整体迁移 tests/measure→measurement/ → 上传大赛规范后发现 §8-04 硬性要求提效对比数据在 tests/ 内,推翻整体迁移回混合方案 → 用户指出 measurement/ 下嵌套 README 有检测风险,精简为两文件结 构(协议说明与可重演命令并入 baseline.md/timing-log.md 正文) | measurement/baseline.md(新建) measurement/timing-log.md(新建) docs/demo-storyboard.md(新建) README.md(修改) | GLM-5.3 |
| 2026-08-29 12:37 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码 实现 → ⑥ 审查验证 | AI 调用稳定性增强(评审「稳定性与易用性 12/15」丢分点:缺少显式重试机制 + AI 调用异常统一兜底):① providers/base.ts 新增 ApiRequestError(携带 HTTP status)与 isRetryableError 分类(EmptyContentError/429/5xx/AbortError/TypeError 可重试,401/4xx/JSON 解析错立即失败);② 三个 provideropenai-compatible/gemini/claude)非 2xx 统一抛结构化 ApiRequestError;③ engine.ts chatWithRetry 由「仅 EmptyContentError 重试 1 次」升级为循环式最多 3 次重试 + 指数退避 1s/2s/4s,按分类决定重试或立即上抛,导出 setRetryBaseDelayForTest 测试钩子;④ aiFixEngine/customFixEngine 的 requestFix 不再吞错(原来 catch 后静默返回 null,用户看不到 AI 失败原因),aiFixDiagnostic/aiFixReviewIssue 调用处 catch 后透传「ai-error: 原因」到 FixResult.message,复用 commands.ts 既有 t('fix.aiFailed') 通知管道;⑤ 扩展 tests/ai-empty-response.test.ts 新增 8 条重试测试(429 重试成功/3 次上限/401 立即失败/TypeError/AbortError/指数退避实测/isRetryableError 分类/重试次数断言),修正 customFixEngine.test.ts 一处测试名。验证:lint 0 error(仅既有 mockDocument 2 warning/ compile 通过 / npm test 190 passing 0 failing | 中间产物:①重试策略设计树三问三选(固定策略 vs 可配置项、分类重试 vs 全部重试、统一错误消息 vs 保持静默降级),用户均选推荐项;②测试文件归属一改——spec 初稿计划新建 tests/ai-retry.test.ts,编码时发现 ai-empty-response.test.ts 本就是 chatWithRetry 测试既有归属,按「优先编辑现有文件」原则改为扩展该文件并同步修订 spec;③fix 兜底范围一扩——spec 初稿仅覆盖 aiFixEngine,编码中核对发现 customFixEngine 存在完全相同的吞错模式(同一评审批评点),按同一模式一并修复;④ chatWithRetry 实现先保留原「单次 catch 重试」结构起草,改写为 for 循环 attempt 计数式(统一处理退避与分类判定)后通过 | docs/superpowers/specs/2026-08-29-ai-retry-design.md(新建) src/ai/providers/base.ts(修改) src/ai/providers/openai-compatible.ts(修改) src/ai/providers/gemini.ts(修改) src/ai/providers/claude.ts(修改) src/ai/engine.ts(修改) src/fix/aiFixEngine.ts(修改) src/fix/customFixEngine.ts(修改) tests/ai-empty-response.test.ts(扩展) tests/customFixEngine.test.ts(更新) | GLM-5.3 |
@@ -0,0 +1,64 @@
# AI 调用稳定性增强(重试 + 统一兜底)设计
日期:2026-08-29
状态:已批准(Stage ④ 通过)
## 背景与目标
评审报告「稳定性与易用性 12/15」指出:缺少显式重试机制;未见对 AI 调用异常的统一兜底。现状核实:
- `chatWithRetry`src/ai/engine.ts)仅对 `EmptyContentError` 重试 1 次,网络错误/超时/429/5xx 均直接上抛
- `requestFix`src/fix/aiFixEngine.tscatch 后静默返回 null,用户看不到 AI 失败原因
- engine 主入口已有 `degraded + error` 兜底结构(`Promise.allSettled` + i18n 错误聚合),无需改动
- 多 AI 供应商切换已被评审认可为降级策略,无需改动
## 方案
### 1. 错误分类(src/ai/providers/base.ts
新增 `ApiRequestError`(携带 HTTP status+ `isRetryableError()`
| 错误 | 可重试 |
|---|---|
| EmptyContentError | 是 |
| HTTP 429 / 5xx | 是 |
| 超时(AbortError / DOMException name==='AbortError' | 是 |
| 网络错误(TypeError | 是 |
| 401、其他 4xx、JSON 解析错 | 否,立即失败 |
### 2. provider 统一结构化抛错
openai-compatible.ts / gemini.ts / claude.ts:非 2xx 响应抛 `ApiRequestError`(携带 status 与响应文本)。401 保留现有 i18n 消息语义,同样结构化、不可重试。
### 3. chatWithRetry 增强(src/ai/engine.ts
固定策略:最多 3 次重试、指数退避 1s/2s/4s、按分类决定重试或立即上抛。所有调用入口(engine 三入口 + fix 链路)自动受益。
### 4. fix 链路兜底透传(src/fix/aiFixEngine.ts
`requestFix` 不再吞错,将最终错误消息透传到 `FixResult.message`,复用 commands.ts 现有 `t('fix.aiFailed')` 通知管道。
### 5. 单元测试
扩展既有 `tests/ai-empty-response.test.ts`chatWithRetry 测试的既有归属,避免新建文件):mock provider 抛可重试/不可重试错误,验证重试次数、退避行为、立即失败路径、`isRetryableError` 分类。`tests/customFixEngine.test.ts` 一处测试名随行为语义更新。
### 实现修订(编码期同步)
- `src/fix/customFixEngine.ts` 存在与 aiFixEngine 相同的吞错模式(同一评审批评点),按同一模式修复:`requestFix` 不再吞错,`aiFixReviewIssue` 调用处 catch 后透传 `ai-error: <原因>``FixResult.message`
- 引擎内部导出 `setRetryBaseDelayForTest` 测试钩子,避免测试真实等待 1s/2s/4s 退避
## 验证链
`npm run lint``npm run compile``npm test`
## 涉及文件
- src/ai/providers/base.ts
- src/ai/providers/openai-compatible.ts
- src/ai/providers/gemini.ts
- src/ai/providers/claude.ts
- src/ai/engine.ts
- src/fix/aiFixEngine.ts
- src/fix/customFixEngine.ts
- tests/ai-empty-response.test.ts
- tests/customFixEngine.test.ts
+23 -7
View File
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { EmptyContentError } from './providers/base';
import { isRetryableError } from './providers/base';
import type { AIProvider, ChatOptions } from './providers/base';
import { createProvider } from './factory';
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage, getApiKey } from '../config';
@@ -83,20 +83,36 @@ export function parseJsonResponse(raw: string): object {
}
}
const RETRY_MAX_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 1000;
let retryBaseDelayMs = RETRY_BASE_DELAY_MS;
export function setRetryBaseDelayForTest(ms: number): void {
retryBaseDelayMs = ms;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function chatWithRetry(
provider: AIProvider,
systemPrompt: string,
userPrompt: string,
options: ChatOptions
): Promise<string> {
try {
return await provider.chat(systemPrompt, userPrompt, options);
} catch (err) {
if (err instanceof EmptyContentError) {
return provider.chat(systemPrompt, userPrompt, options);
for (let attempt = 0; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
try {
return await provider.chat(systemPrompt, userPrompt, options);
} catch (err) {
if (attempt === RETRY_MAX_ATTEMPTS || !isRetryableError(err)) {
throw err;
}
await sleep(retryBaseDelayMs * 2 ** attempt);
}
throw err;
}
throw new Error('unreachable');
}
function buildCustomRuleSystemPrompt(): string {
+29
View File
@@ -28,3 +28,32 @@ export class EmptyContentError extends Error {
this.name = 'EmptyContentError';
}
}
export class ApiRequestError extends Error {
readonly status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'ApiRequestError';
this.status = status;
}
}
export function isRetryableError(err: unknown): boolean {
if (err instanceof EmptyContentError) {
return true;
}
if (err instanceof ApiRequestError) {
if (err.status === undefined) {
return false;
}
return err.status === 429 || err.status >= 500;
}
if (err instanceof DOMException && err.name === 'AbortError') {
return true;
}
if (err instanceof TypeError) {
return true;
}
return false;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class ClaudeProvider extends AIProvider {
@@ -36,9 +36,9 @@ export class ClaudeProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error('API Key 无效,请重新设置');
throw new ApiRequestError('API Key 无效,请重新设置', 401);
}
throw new Error(`Claude API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`Claude API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+2 -2
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class GeminiProvider extends AIProvider {
@@ -36,7 +36,7 @@ export class GeminiProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`Gemini API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+3 -3
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class OpenAICompatibleProvider extends AIProvider {
@@ -47,9 +47,9 @@ export class OpenAICompatibleProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error(t('adapter.invalidApiKey'));
throw new ApiRequestError(t('adapter.invalidApiKey'), 401);
}
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+17 -17
View File
@@ -23,22 +23,12 @@ async function requestFix(
message: diag.message,
suggestion: diag.suggestion,
};
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
}
function sameRuleAtRegion(
@@ -112,7 +102,17 @@ export async function aiFixDiagnostic(
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.range.start.line);
const fix = await requestFix(provider, options, diag, context);
let fix: AiCodeFix | null;
try {
fix = await requestFix(provider, options, diag, context);
} catch (err) {
return {
success: false,
attempts: round,
message: `ai-error: ${err instanceof Error ? err.message : String(err)}`,
appliedFixes,
};
}
if (!fix || fix.originalText.trim() === '') {
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
}
+17 -17
View File
@@ -20,22 +20,12 @@ async function requestFix(
diag: ReviewIssueInput,
context: string
): Promise<AiCodeFix | null> {
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
}
async function verifyFixed(
@@ -108,7 +98,17 @@ export async function aiFixReviewIssue(
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.line);
const fix = await requestFix(provider, options, diag, context);
let fix: AiCodeFix | null;
try {
fix = await requestFix(provider, options, diag, context);
} catch (err) {
return {
success: false,
attempts: round,
message: `ai-error: ${err instanceof Error ? err.message : String(err)}`,
appliedFixes,
};
}
if (!fix || fix.originalText.trim() === '') {
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-no-fix');
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
+125 -3
View File
@@ -1,6 +1,6 @@
import * as assert from 'assert';
import { parseJsonResponse, chatWithRetry } from '../src/ai/engine';
import { EmptyContentError } from '../src/ai/providers/base';
import { parseJsonResponse, chatWithRetry, setRetryBaseDelayForTest } from '../src/ai/engine';
import { EmptyContentError, ApiRequestError, isRetryableError } from '../src/ai/providers/base';
import type { AIProvider } from '../src/ai/providers/base';
import type { ChatOptions } from '../src/ai/providers/base';
import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible';
@@ -13,6 +13,10 @@ const OPTIONS: ChatOptions = {
};
suite('AI Empty Response Handling', () => {
suiteSetup(() => {
setRetryBaseDelayForTest(1);
});
test('parseJsonResponse throws empty-response error on blank input', () => {
assert.throws(() => parseJsonResponse(''), /空响应|empty response/);
assert.throws(() => parseJsonResponse(' \n\t '), /空响应|empty response/);
@@ -41,8 +45,10 @@ suite('AI Empty Response Handling', () => {
});
test('chatWithRetry propagates error when retry also returns empty', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
throw new EmptyContentError('finish_reason=length');
},
} as unknown as AIProvider;
@@ -51,9 +57,10 @@ suite('AI Empty Response Handling', () => {
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
EmptyContentError
);
assert.strictEqual(calls, 4);
});
test('chatWithRetry does not retry on non-empty-content errors', async () => {
test('chatWithRetry does not retry on non-retryable errors', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
@@ -66,6 +73,121 @@ suite('AI Empty Response Handling', () => {
assert.strictEqual(calls, 1);
});
test('chatWithRetry retries on 429 and succeeds', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
if (calls < 3) {
throw new ApiRequestError('API 请求失败 (429): rate limited', 429);
}
return '{"findings":[]}';
},
} as unknown as AIProvider;
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
assert.strictEqual(result, '{"findings":[]}');
assert.strictEqual(calls, 3);
});
test('chatWithRetry retries 429 up to 3 times then throws', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
throw new ApiRequestError('API 请求失败 (429): rate limited', 429);
},
} as unknown as AIProvider;
await assert.rejects(
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
(err: unknown) => err instanceof ApiRequestError
);
assert.strictEqual(calls, 4);
});
test('chatWithRetry fails immediately on 401', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
throw new ApiRequestError('API Key 无效', 401);
},
} as unknown as AIProvider;
await assert.rejects(
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
(err: unknown) => err instanceof ApiRequestError
);
assert.strictEqual(calls, 1);
});
test('chatWithRetry retries on network error (TypeError)', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
if (calls === 1) {
throw new TypeError('fetch failed');
}
return '{"findings":[]}';
},
} as unknown as AIProvider;
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
assert.strictEqual(result, '{"findings":[]}');
assert.strictEqual(calls, 2);
});
test('chatWithRetry retries on timeout (AbortError)', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
calls++;
if (calls === 1) {
throw new DOMException('The operation was aborted', 'AbortError');
}
return '{"findings":[]}';
},
} as unknown as AIProvider;
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
assert.strictEqual(result, '{"findings":[]}');
assert.strictEqual(calls, 2);
});
test('chatWithRetry backoff is exponential', async () => {
setRetryBaseDelayForTest(10);
try {
const provider = {
chat: async (): Promise<string> => {
throw new ApiRequestError('API 请求失败 (503)', 503);
},
} as unknown as AIProvider;
const start = Date.now();
await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS));
const elapsed = Date.now() - start;
assert.ok(elapsed >= 70, `expected backoff >= 70ms, got ${elapsed}ms`);
assert.ok(elapsed < 5000, `backoff too long: ${elapsed}ms`);
} finally {
setRetryBaseDelayForTest(1);
}
});
test('isRetryableError classifies error types', () => {
assert.strictEqual(isRetryableError(new EmptyContentError('x')), true);
assert.strictEqual(isRetryableError(new ApiRequestError('x', 429)), true);
assert.strictEqual(isRetryableError(new ApiRequestError('x', 500)), true);
assert.strictEqual(isRetryableError(new ApiRequestError('x', 503)), true);
assert.strictEqual(isRetryableError(new ApiRequestError('x', 401)), false);
assert.strictEqual(isRetryableError(new ApiRequestError('x', 400)), false);
assert.strictEqual(isRetryableError(new ApiRequestError('x')), false);
assert.strictEqual(isRetryableError(new TypeError('fetch failed')), true);
assert.strictEqual(isRetryableError(new DOMException('abort', 'AbortError')), true);
assert.strictEqual(isRetryableError(new Error('boom')), false);
});
test('openai-compatible reports max_tokens truncation clearly', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => ({
+1 -1
View File
@@ -83,7 +83,7 @@ suite('Custom FixEngine Tests', () => {
assert.strictEqual(result.appliedFixes.length, 1);
});
test('aiFixReviewIssue retries empty fix once then fails with ai-no-fix', async () => {
test('aiFixReviewIssue fails with ai-no-fix when AI provides empty fix', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'const x = BAD;\n',
language: 'javascript',