From d6b8a3c8975f6512b3868307c1f18b94d9a45bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=83=E6=99=BA=E9=B9=8F?= Date: Mon, 31 Aug 2026 21:57:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E8=B0=83=E7=94=A8=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E5=A2=9E=E5=BC=BA=EF=BC=88=E5=8F=AF=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB=20+=20=E6=8C=87?= =?UTF-8?q?=E6=95=B0=E9=80=80=E9=81=BF=20+=20=E4=BF=AE=E5=A4=8D=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E9=94=99=E8=AF=AF=E9=80=8F=E4=BC=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providers/base.ts 引入 ApiRequestError 与 isRetryableError,按 429/5xx/超时/网络错误/401 分类 - chatWithRetry 升级为最多 3 次指数退避(1s/2s/4s),非重试错误立即上抛;新增 setRetryBaseDelayForTest 钩子 - aiFixEngine/customFixEngine 不再吞错,修复失败原因透传为 ai-error: <原因> - 扩展 ai-empty-response/customFixEngine 测试覆盖重试与分类 --- _AI_USAGE_LOG.md | 5 +- .../specs/2026-08-29-ai-retry-design.md | 64 +++++++++ src/ai/engine.ts | 30 +++- src/ai/providers/base.ts | 29 ++++ src/ai/providers/claude.ts | 6 +- src/ai/providers/gemini.ts | 4 +- src/ai/providers/openai-compatible.ts | 6 +- src/fix/aiFixEngine.ts | 34 ++--- src/fix/customFixEngine.ts | 34 ++--- tests/ai-empty-response.test.ts | 128 +++++++++++++++++- tests/customFixEngine.test.ts | 2 +- 11 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-29-ai-retry-design.md diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 80777e0..6b80f8f 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -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 | diff --git a/docs/superpowers/specs/2026-08-29-ai-retry-design.md b/docs/superpowers/specs/2026-08-29-ai-retry-design.md new file mode 100644 index 0000000..a7e57a9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-ai-retry-design.md @@ -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 diff --git a/src/ai/engine.ts b/src/ai/engine.ts index ca71096..6c3c18d 100644 --- a/src/ai/engine.ts +++ b/src/ai/engine.ts @@ -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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export async function chatWithRetry( provider: AIProvider, systemPrompt: string, userPrompt: string, options: ChatOptions ): Promise { - 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 { diff --git a/src/ai/providers/base.ts b/src/ai/providers/base.ts index 4cb9db2..b0268f8 100644 --- a/src/ai/providers/base.ts +++ b/src/ai/providers/base.ts @@ -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; +} diff --git a/src/ai/providers/claude.ts b/src/ai/providers/claude.ts index cf73129..e3770f8 100644 --- a/src/ai/providers/claude.ts +++ b/src/ai/providers/claude.ts @@ -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 { diff --git a/src/ai/providers/gemini.ts b/src/ai/providers/gemini.ts index 35e012a..b190062 100644 --- a/src/ai/providers/gemini.ts +++ b/src/ai/providers/gemini.ts @@ -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 { diff --git a/src/ai/providers/openai-compatible.ts b/src/ai/providers/openai-compatible.ts index 0f6acdf..19d7418 100644 --- a/src/ai/providers/openai-compatible.ts +++ b/src/ai/providers/openai-compatible.ts @@ -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 { diff --git a/src/fix/aiFixEngine.ts b/src/fix/aiFixEngine.ts index fe5aae5..6750b93 100644 --- a/src/fix/aiFixEngine.ts +++ b/src/fix/aiFixEngine.ts @@ -23,22 +23,12 @@ async function requestFix( message: diag.message, suggestion: diag.suggestion, }; - const attempt = async (): Promise => { - try { - const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options); - const parsed = parseJsonResponse(response) as Partial; - 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; + 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 }; } diff --git a/src/fix/customFixEngine.ts b/src/fix/customFixEngine.ts index f1dd727..221693b 100644 --- a/src/fix/customFixEngine.ts +++ b/src/fix/customFixEngine.ts @@ -20,22 +20,12 @@ async function requestFix( diag: ReviewIssueInput, context: string ): Promise { - const attempt = async (): Promise => { - try { - const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options); - const parsed = parseJsonResponse(response) as Partial; - 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; + 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 }; diff --git a/tests/ai-empty-response.test.ts b/tests/ai-empty-response.test.ts index e954f74..f1eb817 100644 --- a/tests/ai-empty-response.test.ts +++ b/tests/ai-empty-response.test.ts @@ -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 => { + 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 => { @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 () => ({ diff --git a/tests/customFixEngine.test.ts b/tests/customFixEngine.test.ts index 0bb04fe..da6b0ef 100644 --- a/tests/customFixEngine.test.ts +++ b/tests/customFixEngine.test.ts @@ -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',