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:
+3
-2
@@ -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+html,include out/src/**/*.js,exclude out/tests 与 out/src/types,仅统计测试触达模块);package.json 新增脚本 test:coverage(compile → 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.0(319MB 缓存于 .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(11,mock global.fetch 测 OpenAI 兼容/Gemini/Claude 三协议 chat)、import-service.test.ts(7)、yaml-parser.test.ts(3)。其中 export-service 因依赖 vscode.window.showSaveDialog(UI 对话框无法在测试宿主 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.md(16 文件 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.filePath;codeReviewer.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 解析错立即失败);② 三个 provider(openai-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.ts)catch 后静默返回 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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user