/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { promises as fs } from 'fs'; import * as path from 'path'; import * as readline from 'readline'; // Edit tool names we're tracking const EDIT_TOOL_NAMES = ['insert_edit_into_file', 'replace_string_in_file', 'multi_replace_string_in_file', 'apply_patch']; // Tool names that indicate a continuation/retry attempt const CONTINUATION_TOOL_NAMES = ['read_file']; interface EditLogEntry { timestamp: string; requestId: string; success: boolean; input: string; healed?: string; } interface ToolCall { toolName: string; requestId: string; timestamp?: string; } interface EditOperation { toolName: string; requestId: string; timestamp: string; success: boolean; wasHealed: boolean; originalInput: string; healedInput?: string; turnIndex: number; isRetry: boolean; retrySucceeded?: boolean; } interface ConversationAnalysis { conversationPath: string; edits: EditOperation[]; totalEdits: number; successfulEdits: number; failedEdits: number; healedEdits: number; successfulEditsWithRetries: number; totalUniqueEdits: number; modelName?: string; } interface RunAnalysis { runId: string; conversations: ConversationAnalysis[]; totalEdits: number; successRate: number; healingRate: number; successRateWithRetries: number; totalUniqueEdits: number; modelName?: string; } async function listRuns(amlOutPath: string): Promise { const entries = await fs.readdir(amlOutPath, { withFileTypes: true }); const runs = entries .filter(e => e.isDirectory() && e.name.startsWith('msbench-')) .map(e => e.name.replace('msbench-', '')) .sort((a, b) => parseInt(b) - parseInt(a)); // Sort descending (newest first) return runs; } async function promptUserForRun(runs: string[]): Promise { console.log('\nAvailable test runs (newest first):'); runs.slice(0, 10).forEach((run, i) => { console.log(` ${i + 1}. ${run}`); }); if (runs.length > 10) { console.log(` ... and ${runs.length - 10} more`); } const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question('\nEnter run number (or press Enter for the most recent): ', (answer) => { rl.close(); const choice = answer.trim(); if (choice === '') { resolve(runs[0]); } else { const index = parseInt(choice) - 1; if (index >= 0 && index < runs.length) { resolve(runs[index]); } else { console.log('Invalid selection, using most recent run.'); resolve(runs[0]); } } }); }); } async function parseEditLogs(simLogPath: string): Promise { const editLogs: EditLogEntry[] = []; try { const content = await fs.readFile(simLogPath, 'utf-8'); const lines = content.split('\n'); for (const line of lines) { const editToolMatch = line.match(/\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\] \[edit-tool:([^\]]+)\] (.+)$/); if (editToolMatch) { const [, timestamp, _requestId, jsonData] = editToolMatch; try { const data = JSON.parse(jsonData); // The data can be either an object or an array with one object const entry = Array.isArray(data) ? data : [data]; if (entry && entry.length && entry[0]) { editLogs.push({ timestamp, requestId: entry[0].requestId ?? _requestId, success: entry.every(e => e.success), input: entry[0].input, healed: entry[0].healed }); } } catch (e) { console.warn(`Failed to parse edit log JSON: ${jsonData.substring(0, 100)}...`); } } } } catch (error) { console.warn(`Could not read sim-log file: ${simLogPath}`); } return editLogs; } async function parseToolCalls(simRequestsPath: string): Promise { const toolCalls: ToolCall[] = []; try { const content = await fs.readFile(simRequestsPath, 'utf-8'); const requests = JSON.parse(content); // The file contains an array of request objects if (Array.isArray(requests)) { for (const request of requests) { const requestId = request.response?.requestId; const copilotFunctionCalls = request.response?.copilotFunctionCalls || []; for (const call of copilotFunctionCalls) { // Track ALL tool calls (edit tools + read_file for retry detection) if (call.name) { toolCalls.push({ toolName: call.name, requestId: requestId || 'unknown' }); } } } } } catch (error) { console.warn(`Could not read sim-requests file: ${simRequestsPath}`); } return toolCalls; } async function analyzeConversation(conversationPath: string): Promise { const simLogPath = path.join(conversationPath, 'sim-log-0.txt'); const simRequestsPath = path.join(conversationPath, 'sim-requests-0.txt'); const editLogs = await parseEditLogs(simLogPath); const toolCalls = await parseToolCalls(simRequestsPath); // Extract model name from first request let modelName: string | undefined; try { const content = await fs.readFile(simRequestsPath, 'utf-8'); const requests = JSON.parse(content); if (Array.isArray(requests) && requests.length > 0) { modelName = requests[0]?.model || requests[0]?.response?.model; } } catch (e) { // Model name is optional } const edits: EditOperation[] = []; let turnIndex = 0; // Match edit tool calls to edit logs sequentially let editLogIndex = 0; for (let i = 0; i < toolCalls.length; i++) { const toolCall = toolCalls[i]; if (!EDIT_TOOL_NAMES.includes(toolCall.toolName)) { continue; } const logEntry = editLogs[editLogIndex++]; if (!logEntry) { break; } // Detect retry pattern: failed edit -> continuation tool -> another edit let isRetry = false; let retrySucceeded: boolean | undefined; if (!logEntry.success) { // Look ahead to see if there's a continuation tool followed by another edit let j = i + 1; let foundContinuationTool = false; while (j < toolCalls.length && j < i + 10) { // Look ahead max 10 calls if (CONTINUATION_TOOL_NAMES.includes(toolCalls[j].toolName)) { foundContinuationTool = true; } else if (foundContinuationTool && EDIT_TOOL_NAMES.includes(toolCalls[j].toolName)) { // Found a retry! isRetry = true; const retryLogEntry = editLogs[editLogIndex]; if (retryLogEntry) { retrySucceeded = retryLogEntry.success; } break; } else if (EDIT_TOOL_NAMES.includes(toolCalls[j].toolName)) { // Another edit without continuation tool in between, not a retry break; } j++; } } edits.push({ toolName: toolCall.toolName, requestId: toolCall.requestId, timestamp: logEntry.timestamp, success: logEntry.success, wasHealed: !!logEntry.healed && logEntry.healed !== logEntry.input, originalInput: logEntry.input, healedInput: logEntry.healed, turnIndex: turnIndex++, isRetry, retrySucceeded }); } const successfulEdits = edits.filter(e => e.success).length; const healedEdits = edits.filter(e => e.wasHealed).length; // Calculate success rate accounting for retries (final outcome only) const editsWithRetries = edits.filter(e => !e.success && e.isRetry); const retriedSuccesses = editsWithRetries.filter(e => e.retrySucceeded).length; const successfulEditsWithRetries = successfulEdits + retriedSuccesses; const totalUniqueEdits = edits.length - editsWithRetries.length + editsWithRetries.filter(e => e.retrySucceeded !== undefined).length; return { conversationPath, edits, totalEdits: edits.length, successfulEdits, failedEdits: edits.length - successfulEdits, healedEdits, successfulEditsWithRetries, totalUniqueEdits, modelName }; } async function analyzeRun(runId: string, basePath: string): Promise { const runPath = path.join(basePath, `msbench-${runId}`, 'simulate', 'simulator_output_dir', 'simulator_output'); const conversations: ConversationAnalysis[] = []; try { const entries = await fs.readdir(runPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const conversationPath = path.join(runPath, entry.name); const analysis = await analyzeConversation(conversationPath); if (analysis.totalEdits > 0) { conversations.push(analysis); } } } } catch (error) { console.error(`Error reading run directory: ${error}`); } const totalEdits = conversations.reduce((sum, c) => sum + c.totalEdits, 0); const totalSuccessful = conversations.reduce((sum, c) => sum + c.successfulEdits, 0); const totalHealed = conversations.reduce((sum, c) => sum + c.healedEdits, 0); const totalSuccessfulWithRetries = conversations.reduce((sum, c) => sum + c.successfulEditsWithRetries, 0); const totalUniqueEdits = conversations.reduce((sum, c) => sum + c.totalUniqueEdits, 0); // Get model name from first conversation that has one const modelName = conversations.find(c => c.modelName)?.modelName; return { runId, conversations, totalEdits, successRate: totalEdits > 0 ? totalSuccessful / totalEdits : 0, healingRate: totalEdits > 0 ? totalHealed / totalEdits : 0, successRateWithRetries: totalUniqueEdits > 0 ? totalSuccessfulWithRetries / totalUniqueEdits : 0, totalUniqueEdits, modelName }; } function generateHTML(analysis: RunAnalysis, outputPath: string, showHealing: boolean = true, includeRetries: boolean = false): string { // Build Sankey data const sankeyNodes: string[] = []; const sankeyLinks: Array<{ source: number; target: number; value: number }> = []; const nodeMap = new Map(); const getNodeIndex = (name: string): number => { if (!nodeMap.has(name)) { nodeMap.set(name, sankeyNodes.length); sankeyNodes.push(name); } return nodeMap.get(name)!; }; // Track flows const flows = new Map(); for (const conv of analysis.conversations) { for (const edit of conv.edits) { const toolNode = edit.toolName; // Check if this is a failed edit with a retry if (includeRetries && !edit.success && edit.isRetry && edit.retrySucceeded !== undefined) { // Show full retry flow: Tool -> Failed -> read_file -> Retry Edit -> Final Result if (showHealing && edit.wasHealed) { const healedNode = 'Healed'; const failedNode = 'Failed (will retry)'; const readFileNode = 'read_file'; const retryEditNode = `${toolNode} (retry)`; const finalResult = edit.retrySucceeded ? 'Success' : 'Failed'; flows.set(`${toolNode}->${healedNode}`, (flows.get(`${toolNode}->${healedNode}`) || 0) + 1); flows.set(`${healedNode}->${failedNode}`, (flows.get(`${healedNode}->${failedNode}`) || 0) + 1); flows.set(`${failedNode}->${readFileNode}`, (flows.get(`${failedNode}->${readFileNode}`) || 0) + 1); flows.set(`${readFileNode}->${retryEditNode}`, (flows.get(`${readFileNode}->${retryEditNode}`) || 0) + 1); flows.set(`${retryEditNode}->${finalResult}`, (flows.get(`${retryEditNode}->${finalResult}`) || 0) + 1); } else { const failedNode = 'Failed (will retry)'; const readFileNode = 'read_file'; const retryEditNode = `${toolNode} (retry)`; const finalResult = edit.retrySucceeded ? 'Success' : 'Failed'; flows.set(`${toolNode}->${failedNode}`, (flows.get(`${toolNode}->${failedNode}`) || 0) + 1); flows.set(`${failedNode}->${readFileNode}`, (flows.get(`${failedNode}->${readFileNode}`) || 0) + 1); flows.set(`${readFileNode}->${retryEditNode}`, (flows.get(`${readFileNode}->${retryEditNode}`) || 0) + 1); flows.set(`${retryEditNode}->${finalResult}`, (flows.get(`${retryEditNode}->${finalResult}`) || 0) + 1); } continue; } if (showHealing && edit.wasHealed) { // Tool -> Healed -> Success/Fail const healedNode = 'Healed'; const resultNode = edit.success ? 'Success (healed)' : 'Failed (healed)'; const flow1Key = `${toolNode}->${healedNode}`; const flow2Key = `${healedNode}->${resultNode}`; flows.set(flow1Key, (flows.get(flow1Key) || 0) + 1); flows.set(flow2Key, (flows.get(flow2Key) || 0) + 1); } else { // Tool -> Success/Fail const resultNode = edit.success ? 'Success' : 'Failed'; const flowKey = `${toolNode}->${resultNode}`; flows.set(flowKey, (flows.get(flowKey) || 0) + 1); } } } // Convert flows to Sankey links for (const [flowKey, count] of flows.entries()) { const [source, target] = flowKey.split('->'); sankeyLinks.push({ source: getNodeIndex(source), target: getNodeIndex(target), value: count }); } // Build table rows const tableRows = analysis.conversations.flatMap(conv => conv.edits.map(edit => ({ conversation: path.basename(conv.conversationPath), toolName: edit.toolName, timestamp: edit.timestamp, success: edit.success, wasHealed: edit.wasHealed, turnIndex: edit.turnIndex, isRetry: edit.isRetry, retrySucceeded: edit.retrySucceeded })) ); const html = ` Run ${analysis.runId}${analysis.modelName ? ' - ' + analysis.modelName : ''}

šŸ”§ Run ${analysis.runId}${analysis.modelName ? ' - ' + analysis.modelName : ''}

Analysis of edit tool operations and success rates

Total Edits
${analysis.totalEdits}
Success Rate
${(analysis.successRate * 100).toFixed(1)}%
Healing Rate
${(analysis.healingRate * 100).toFixed(1)}%
Conversations
${analysis.conversations.length}

Edit Operations

${tableRows.map(row => ` `).join('')}
Conversation Tool Turn Timestamp Status Healed Retry
${row.conversation} ${row.toolName} ${row.turnIndex} ${row.timestamp} ${row.success ? 'āœ“ Success' : 'āœ— Failed'} ${row.wasHealed ? 'Healed' : '-'} ${row.isRetry ? (row.retrySucceeded === true ? 'āœ“ Retry Success' : row.retrySucceeded === false ? 'āœ— Retry Failed' : 'Retry Pending') : '-'}
`; return html; } async function main() { const args = process.argv.slice(2); const runIdArg = args.find(arg => arg.startsWith('--runId=')); const basePath = path.join(__dirname, '..', 'test', 'aml', 'out'); let runId: string; if (runIdArg) { runId = runIdArg.split('=')[1]; console.log(`Using run ID: ${runId}`); } else { const runs = await listRuns(basePath); if (runs.length === 0) { console.error('No test runs found in', basePath); process.exit(1); } runId = await promptUserForRun(runs); console.log(`Selected run: ${runId}`); } console.log('\nAnalyzing run...'); const analysis = await analyzeRun(runId, basePath); console.log(`\nFound ${analysis.conversations.length} conversations with edits`); console.log(`Total edits: ${analysis.totalEdits}`); console.log(`Success rate: ${(analysis.successRate * 100).toFixed(1)}%`); console.log(`Healing rate: ${(analysis.healingRate * 100).toFixed(1)}%`); const outputPath = path.join(basePath, `msbench-${runId}`, 'simulate', 'edit-analysis.html'); const html = generateHTML(analysis, outputPath, true); await fs.writeFile(outputPath, html, 'utf-8'); console.log(`\nāœ“ Analysis complete! Generated: ${outputPath}`); } main().catch(console.error);