改用 AI 助理 CLI 並強化 AI Code Review 執行流程 #13

Merged
admin merged 19 commits from ai-review-resolve/develop-20260626-091820 into develop 2026-06-29 08:49:20 +00:00
3 changed files with 56 additions and 7 deletions
Showing only changes of commit 93be261b90 - Show all commits
+27 -1
View File
1
@@ -32,6 +32,10 @@ function opencodeAxiosOptions(headers) {
}; };
} }
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** /**
* 從 OpenCode 訊息回應中抽取並串接所有文字片段。 * 從 OpenCode 訊息回應中抽取並串接所有文字片段。
* *
@@ -66,6 +70,11 @@ function formatOpenCodeError(e) {
return response ? `${statusText}: ${response}` : statusText; return response ? `${statusText}: ${response}` : statusText;
} }
function isTransientOpenCodeError(e) {
const status = e.response?.status;
return status === 500 || status === 502 || status === 503 || status === 504 || status === 429;
}
/** /**
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
* *
@@ -104,6 +113,23 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
return { content: extractOpenCodeContent(resp.data), data: resp.data }; return { content: extractOpenCodeContent(resp.data), data: resp.data };
} }
async function chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers) {
const maxAttempts = Number(process.env.OPENCODE_RETRY_ATTEMPTS || 3);
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
} catch (e) {
lastError = e;
if (!isTransientOpenCodeError(e) || attempt === maxAttempts) throw e;
const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
line(`[LLM] OpenCode 暫時性錯誤,${delay}ms 後重試 (${attempt}/${maxAttempts}): ${formatOpenCodeError(e)}`);
await sleep(delay);
}
}
throw lastError;
}
/** /**
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
* *
@@ -125,7 +151,7 @@ export async function chat(systemPrompt, userContent) {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
try { try {
const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); const { content, data } = await chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers);
recordUsage(data); recordUsage(data);
return content; return content;
} catch (e) { } catch (e) {
+6 -5
View File
@@ -120,15 +120,16 @@ async function main() {
} catch (e) { } catch (e) {
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
} }
const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
const newFindings = []; const newFindings = [];
let fulfilledAnalyses = 0; let fulfilledAnalyses = 0;
for (let i = 0; i < analyses.length; i++) { for (const role of roles) {
if (analyses[i].status === 'fulfilled') { try {
const findings = await analyzeWithRole(role, diff);
fulfilledAnalyses += 1; fulfilledAnalyses += 1;
newFindings.push(...analyses[i].value); newFindings.push(...findings);
} catch (e) {
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
} }
else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`);
} }
if (fulfilledAnalyses === 0) { if (fulfilledAnalyses === 0) {
result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過'); result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過');
+23 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import { extractBalancedJSON, extractJSONText } from '../llm.js'; import { extractBalancedJSON, extractJSONText } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS',
]; ];
let saved = {}; let saved = {};
@@ -87,6 +87,7 @@ describe('chat - OpenCode', async () => {
it('throws an error when OpenCode fails instead of exiting the process', async () => { it('throws an error when OpenCode fails instead of exiting the process', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_RETRY_ATTEMPTS = '1';
mock.method(axios, 'post', async () => { mock.method(axios, 'post', async () => {
const err = new Error('Request failed with status code 500'); const err = new Error('Request failed with status code 500');
err.response = { status: 500, data: { error: 'provider overloaded' } }; err.response = { status: 500, data: { error: 'provider overloaded' } };
@@ -98,6 +99,27 @@ describe('chat - OpenCode', async () => {
assert.equal(exitMock.mock.calls.length, 0); assert.equal(exitMock.mock.calls.length, 0);
}); });
it('retries transient OpenCode failures before returning content', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_RETRY_ATTEMPTS = '2';
let messageAttempts = 0;
mock.method(axios, 'post', async (url) => {
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
messageAttempts += 1;
if (messageAttempts === 1) {
const err = new Error('Request failed with status code 500');
err.response = { status: 500, data: { error: 'temporary failure' } };
throw err;
}
return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } };
});
const result = await chat('sys', 'user');
assert.equal(result, 'ok after retry');
assert.equal(messageAttempts, 2);
});
}); });
describe('chatJSON', async () => { describe('chatJSON', async () => {