diff --git a/app/git.js b/app/git.js index 38c5582..39468fe 100644 --- a/app/git.js +++ b/app/git.js @@ -203,6 +203,10 @@ export async function mergeInstructionText(existingText, sourceText, relPath, ai try { const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks }); + if (aiMerged == null) { + warn(`[merge] ${relPath} AI result unavailable; using deterministic merge`); + return deterministic; + } if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) { return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged; } diff --git a/app/git.test.js b/app/git.test.js index 5d92566..e766a0d 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -211,6 +211,15 @@ describe('commitAndPush', () => { assert.ok(result.includes('extra block')); }); + it('uses deterministic instruction merge when AI returns no usable result', async () => { + const aiMergeAssistant = async () => null; + + const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant); + + assert.ok(result.includes('repo block')); + assert.ok(result.includes('source block')); + }); + it('exits when AI output drops a block', async () => { const originalExit = process.exit; let exitCode = null; diff --git a/app/llm.js b/app/llm.js index a686053..12a0dd7 100644 --- a/app/llm.js +++ b/app/llm.js @@ -111,9 +111,69 @@ export async function chat(systemPrompt, userContent) { export async function chatJSON(systemPrompt, userContent) { const text = await chat(systemPrompt, userContent); try { - return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); + return JSON.parse(extractJSONText(text)); } catch (e) { line(`[LLM] JSON 解析失敗: ${e.message}`); return []; } } + +function stripOuterFence(text) { + return String(text) + .trim() + .replace(/^```[a-zA-Z0-9_-]*\n?/, '') + .replace(/```$/, '') + .trim(); +} + +function extractBalancedJSON(text, startIndex) { + const source = String(text); + const open = source[startIndex]; + const close = open === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = startIndex; i < source.length; i++) { + const ch = source[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + if (depth === 0) return source.slice(startIndex, i + 1); + } + } + return null; +} + +function extractJSONText(text) { + const stripped = stripOuterFence(text); + try { + JSON.parse(stripped); + return stripped; + } catch {} + + for (let i = 0; i < stripped.length; i++) { + if (stripped[i] !== '[' && stripped[i] !== '{') continue; + const candidate = extractBalancedJSON(stripped, i); + if (!candidate) continue; + try { + JSON.parse(candidate); + return candidate; + } catch {} + } + return stripped; +} diff --git a/app/llm.test.js b/app/llm.test.js index be2756a..2024946 100644 --- a/app/llm.test.js +++ b/app/llm.test.js @@ -208,6 +208,20 @@ describe('chatJSON', async () => { assert.deepEqual(result, [{ level: 'info' }]); }); + it('extracts JSON array from surrounding prose', async () => { + process.env.OPENAI_API_KEY = 'sk-test'; + mockAxiosPost([makeOkResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.')]); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]); + }); + + it('extracts JSON object from surrounding prose', async () => { + process.env.OPENAI_API_KEY = 'sk-test'; + mockAxiosPost([makeOkResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}')]); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' }); + }); + it('returns [] when JSON is invalid', async () => { process.env.OPENAI_API_KEY = 'sk-test'; mockAxiosPost([makeOkResponse('not json')]);