Skip to content

Commit c5516ba

Browse files
author
Brendan Gray
committed
Fix 28-31: Code block persistence, unified file display, append salvage
Fix 28: Salvaged write_file now sends mcp-executing-tools + mcp-tool-results with actual content, pushes to allToolResults. Frontend fallback promotes orphaned tool results. Fix 30: Unified per-file code block render (one block per file across write_file/append_to_file). Backend lineCount in llm-tool-generating IPC. fileContentAccRef accumulator. Scroll stability (removed scrollToIndex during generation). onMcpToolResults matches write tools by filePath. Fix 31: Salvage now handles append_to_file (not just write_file). Append content no longer lost during continuation. files-changed + open-file IPC events for salvage path.
1 parent d4b09fe commit c5516ba

3 files changed

Lines changed: 269 additions & 144 deletions

File tree

main/agenticChat.js

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,6 +1198,13 @@ function register(ctx) {
11981198
}
11991199
mainWindow.webContents.send('llm-tool-generating', {
12001200
callIndex: _tIdx, functionName: _tName, paramsText, done: false,
1201+
// Fix 30B: Send accurate line count from untruncated content
1202+
lineCount: (() => {
1203+
const cIdx = raw.indexOf('"content"');
1204+
if (cIdx === -1) return 0;
1205+
const afterContent = raw.slice(cIdx);
1206+
return (afterContent.match(/\\n/g) || []).length + 1;
1207+
})(),
12011208
});
12021209
}
12031210
}, (thinkToken) => {
@@ -1803,16 +1810,17 @@ function register(ctx) {
18031810
if (_hasUnclosedToolFence) {
18041811
const partialFence = _stitchedForMcp.slice(_fenceIdx);
18051812

1806-
// ── Salvage-and-Append: when a write_file call is truncated mid-content,
1807-
// salvage the partial file, write it to disk, then switch the model to
1808-
// append_to_file for the remaining content. This prevents the model from
1809-
// re-generating the entire file (which causes 87%+ overlap → rotation). ──
1813+
// ── Salvage-and-Append: when a write_file or append_to_file call is
1814+
// truncated mid-content, salvage the partial content, write/append it
1815+
// to disk, then tell the model to continue with append_to_file for the
1816+
// remaining content. This prevents content loss during long generations. ──
18101817
const _isWriteFile = partialFence.includes('"write_file"');
1818+
const _isAppendFile = partialFence.includes('"append_to_file"');
18111819
const _hasFP = /"filePath"\s*:\s*"[^"]+"/.test(partialFence);
18121820
const _hasLongContent = /"content"\s*:\s*"[\s\S]{200,}/.test(partialFence);
18131821

18141822
let _didSalvageAppend = false;
1815-
if (_isWriteFile && _hasFP && _hasLongContent) {
1823+
if ((_isWriteFile || _isAppendFile) && _hasFP && _hasLongContent) {
18161824
const salvaged = salvagePartialToolCall(_stitchedForMcp, _fenceIdx);
18171825
if (salvaged) {
18181826
try {
@@ -1822,47 +1830,64 @@ function register(ctx) {
18221830
const salvageContent = salvageJson?.params?.content || '';
18231831

18241832
if (salvagePath && salvageContent.length >= 100) {
1825-
const writeResult = await mcpToolServer.executeTool('write_file', {
1833+
const salvageTool = _isAppendFile ? 'append_to_file' : 'write_file';
1834+
const writeResult = await mcpToolServer.executeTool(salvageTool, {
18261835
filePath: salvagePath,
18271836
content: salvageContent,
18281837
});
1829-
const lineCount = salvageContent.split('\n').length;
1830-
console.log(`[AI Chat] Salvage-and-append: wrote ${lineCount} lines to "${salvagePath}"`);
1838+
// For append, use fullContent (entire file) for accurate line count
1839+
const finalContent = (salvageTool === 'append_to_file' && writeResult?.fullContent)
1840+
? writeResult.fullContent : salvageContent;
1841+
const lineCount = finalContent.split('\n').length;
1842+
console.log(`[AI Chat] Salvage-and-append: ${salvageTool === 'append_to_file' ? 'appended' : 'wrote'} ${lineCount} lines to "${salvagePath}"`);
18311843

18321844
// Update writeFileHistory — blocks future write_file, forces append_to_file
18331845
// Set count=2 (not 1) so guard fires even when continuationCount resets to 0
18341846
// (wfLimit = continuationCount>0 ? 1 : 2; guard fires when count >= wfLimit)
18351847
if (!writeFileHistory[salvagePath]) writeFileHistory[salvagePath] = { count: 0, maxLen: 0 };
18361848
writeFileHistory[salvagePath].count = Math.max(writeFileHistory[salvagePath].count + 1, 2);
1837-
if (salvageContent.length > writeFileHistory[salvagePath].maxLen) {
1838-
writeFileHistory[salvagePath].maxLen = salvageContent.length;
1849+
if (finalContent.length > writeFileHistory[salvagePath].maxLen) {
1850+
writeFileHistory[salvagePath].maxLen = finalContent.length;
18391851
}
18401852

1841-
// Send UI event for the artifact
1853+
// Send UI events for the artifact — first executing, then results,
1854+
// so the frontend's completedStreamingTools picks up the code block.
1855+
// For append, use fullContent so the unified code block shows the entire file.
1856+
const salvageDisplayContent = (salvageTool === 'append_to_file' && writeResult?.fullContent)
1857+
? writeResult.fullContent : salvageContent;
1858+
const salvageToolEntry = {
1859+
tool: salvageTool,
1860+
params: { filePath: salvagePath, content: salvageDisplayContent },
1861+
result: writeResult,
1862+
};
18421863
if (mainWindow && !mainWindow.isDestroyed()) {
1843-
mainWindow.webContents.send('mcp-tool-results', [{
1844-
tool: 'write_file',
1845-
params: { filePath: salvagePath, content: '...(salvaged partial)' },
1846-
result: writeResult,
1847-
}]);
1864+
mainWindow.webContents.send('mcp-executing-tools', [{ tool: salvageTool, params: { filePath: salvagePath, content: salvageDisplayContent } }]);
1865+
mainWindow.webContents.send('mcp-tool-results', [salvageToolEntry]);
1866+
// Notify file explorer that a file was created/modified
1867+
mainWindow.webContents.send('files-changed');
1868+
mainWindow.webContents.send('open-file', salvagePath);
18481869
}
1870+
// Track in allToolResults so the committed message includes this code block
1871+
allToolResults.push(salvageToolEntry);
18491872

18501873
// Track in summarizers + execution state (include content for accurate line counting)
18511874
try {
18521875
const salvageParams = { filePath: salvagePath, content: salvageContent };
1853-
summarizer.recordToolCall('write_file', salvageParams, writeResult);
1854-
rollingSummary.recordToolCall('write_file', salvageParams, writeResult, iteration);
1855-
rollingSummary.recordToolResult('write_file', salvageParams, writeResult, iteration);
1856-
executionState.update('write_file', salvageParams, writeResult, iteration);
1876+
summarizer.recordToolCall(salvageTool, salvageParams, writeResult);
1877+
rollingSummary.recordToolCall(salvageTool, salvageParams, writeResult, iteration);
1878+
rollingSummary.recordToolResult(salvageTool, salvageParams, writeResult, iteration);
1879+
executionState.update(salvageTool, salvageParams, writeResult, iteration);
18571880
} catch (_) {}
18581881

18591882
// Build continuation prompt with completeness detection
1860-
const allLines = salvageContent.split('\n');
1883+
// For append, use finalContent (full file) for completeness check
1884+
const checkContent = finalContent;
1885+
const allLines = checkContent.split('\n');
18611886
const lastLines = allLines.slice(-10).join('\n');
18621887
_pendingPartialBlock = null; // switch from JSON stitching to free-form
18631888

18641889
// Heuristic: detect if salvaged file looks syntactically complete
1865-
const trimmedEnd = salvageContent.trimEnd();
1890+
const trimmedEnd = checkContent.trimEnd();
18661891
const lastCodeLine = trimmedEnd.split('\n').pop().trim();
18671892
const _ext = (salvagePath.match(/\.([^.]+)$/) || [])[1] || '';
18681893
let looksComplete = false;
@@ -1876,7 +1901,7 @@ function register(ctx) {
18761901
looksComplete = /^(module\.exports\s*=|export\s+(default\s+)?|\}\s*;?\s*$|\}\)\s*;?\s*$)/.test(lastCodeLine);
18771902
}
18781903
// Secondary check: if file has open HTML tags without closing counterparts, it's not complete
1879-
if (looksComplete && /<(style|script)\b/i.test(salvageContent) && !/<\/(style|script)\s*>/i.test(salvageContent)) {
1904+
if (looksComplete && /<(style|script)\b/i.test(checkContent) && !/<\/(style|script)\s*>/i.test(checkContent)) {
18801905
looksComplete = false;
18811906
}
18821907

@@ -2183,7 +2208,15 @@ function register(ctx) {
21832208
const uiToolResults = toolResults.results.filter(tr => !tr._deferred);
21842209

21852210
// Accumulate only non-deferred tool results for UI
2186-
allToolResults.push(...uiToolResults);
2211+
// For append_to_file, replace params.content with the full file content
2212+
// so the committed message shows one unified code block per file
2213+
const enrichedForStorage = uiToolResults.map(tr => {
2214+
if (tr.tool === 'append_to_file' && tr.result?.fullContent) {
2215+
return { ...tr, params: { ...tr.params, content: tr.result.fullContent } };
2216+
}
2217+
return tr;
2218+
});
2219+
allToolResults.push(...enrichedForStorage);
21872220
capArray(allToolResults, 50);
21882221

21892222
// Compress old tool results
@@ -2244,7 +2277,17 @@ function register(ctx) {
22442277
snapFeedback = `\n### Page snapshot after ${snapResult.triggerTool}\n${snapResult.snapshotText}\n\n**${snapResult.elementCount} elements.** Use [ref=N] with browser_click/type.\n`;
22452278
}
22462279

2247-
if (mainWindow) mainWindow.webContents.send('mcp-tool-results', uiToolResults);
2280+
if (mainWindow) {
2281+
// For append_to_file, replace params.content with the full file content
2282+
// so the frontend can display one unified code block per file
2283+
const enrichedResults = uiToolResults.map(tr => {
2284+
if (tr.tool === 'append_to_file' && tr.result?.fullContent) {
2285+
return { ...tr, params: { ...tr.params, content: tr.result.fullContent } };
2286+
}
2287+
return tr;
2288+
});
2289+
mainWindow.webContents.send('mcp-tool-results', enrichedResults);
2290+
}
22482291
fullResponseText += toolFeedback + snapFeedback;
22492292
if (fullResponseText.length > MAX_RESPONSE_SIZE) {
22502293
fullResponseText = fullResponseText.substring(fullResponseText.length - MAX_RESPONSE_SIZE);
@@ -2407,12 +2450,29 @@ function register(ctx) {
24072450
const localTokensUsed = estimateTokens(fullResponseText);
24082451
_reportTokenStats(localTokensUsed, mainWindow);
24092452

2453+
// Dedup write tools by filePath: keep only the latest entry per file
2454+
// so the committed message shows one unified code block per file
2455+
const WRITE_TOOLS_DEDUP = new Set(['write_file', 'create_file', 'edit_file', 'append_to_file']);
2456+
const writePathLatest = new Map();
2457+
for (let i = allToolResults.length - 1; i >= 0; i--) {
2458+
const tr = allToolResults[i];
2459+
if (WRITE_TOOLS_DEDUP.has(tr.tool) && tr.params?.filePath) {
2460+
if (!writePathLatest.has(tr.params.filePath)) {
2461+
writePathLatest.set(tr.params.filePath, i);
2462+
}
2463+
}
2464+
}
2465+
const dedupedToolResults = allToolResults.filter((tr, idx) => {
2466+
if (!WRITE_TOOLS_DEDUP.has(tr.tool) || !tr.params?.filePath) return true;
2467+
return writePathLatest.get(tr.params.filePath) === idx;
2468+
});
2469+
24102470
return {
24112471
success: true,
24122472
text: cleanResponse,
24132473
model: modelStatus.modelInfo?.name || 'local',
24142474
tokensUsed: localTokensUsed,
2415-
toolResults: allToolResults.length > 0 ? allToolResults : undefined,
2475+
toolResults: dedupedToolResults.length > 0 ? dedupedToolResults : undefined,
24162476
iterations: iteration,
24172477
};
24182478
}

main/mcpToolServer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1921,7 +1921,7 @@ class MCPToolServer {
19211921
});
19221922
}
19231923

1924-
return { success: true, path: fullPath, isNew, message: `Appended ${content.length} chars to ${path.basename(fullPath)}` };
1924+
return { success: true, path: fullPath, isNew, message: `Appended ${content.length} chars to ${path.basename(fullPath)}`, fullContent };
19251925
} catch (error) {
19261926
return { success: false, error: error.message };
19271927
}

0 commit comments

Comments
 (0)