Initial commit to new repository
This commit is contained in:
385
.decompiledproj/AxCopilot/Services/Agent/TestLoopTool.cs
Normal file
385
.decompiledproj/AxCopilot/Services/Agent/TestLoopTool.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public class TestLoopTool : IAgentTool
|
||||
{
|
||||
private class FailureDetail
|
||||
{
|
||||
public string TestName { get; set; } = "";
|
||||
|
||||
public string FilePath { get; set; } = "";
|
||||
|
||||
public int Line { get; set; }
|
||||
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public string Name => "test_loop";
|
||||
|
||||
public string Description => "코드 변경에 대한 테스트를 자동으로 생성하고 실행합니다.\n- action=\"generate\": 변경된 파일에 대한 테스트 코드 생성 제안\n- action=\"run\": 프로젝트의 테스트를 실행하고 결과 반환\n- action=\"analyze\": 테스트 결과를 분석하여 수정 방향 제시\n- action=\"auto_fix\": 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환 (반복 수정용)\n테스트 프레임워크를 자동 감지합니다 (xUnit, NUnit, MSTest, pytest, Jest 등).";
|
||||
|
||||
public ToolParameterSchema Parameters => new ToolParameterSchema
|
||||
{
|
||||
Properties = new Dictionary<string, ToolProperty>
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "수행할 작업: generate | run | analyze | auto_fix",
|
||||
Enum = new List<string> { "generate", "run", "analyze", "auto_fix" }
|
||||
},
|
||||
["file_path"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "대상 소스 파일 경로 (generate 시 필요)"
|
||||
},
|
||||
["test_output"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "분석할 테스트 출력 (analyze 시 필요)"
|
||||
}
|
||||
},
|
||||
Required = new List<string> { "action" }
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
|
||||
{
|
||||
JsonElement a;
|
||||
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
|
||||
if (1 == 0)
|
||||
{
|
||||
}
|
||||
ToolResult result = action switch
|
||||
{
|
||||
"generate" => GenerateTestSuggestion(args, context),
|
||||
"run" => await RunTestsAsync(context, ct),
|
||||
"analyze" => AnalyzeTestOutput(args),
|
||||
"auto_fix" => await AutoFixAsync(context, ct),
|
||||
_ => ToolResult.Fail("action은 generate, run, analyze, auto_fix 중 하나여야 합니다."),
|
||||
};
|
||||
if (1 == 0)
|
||||
{
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
|
||||
{
|
||||
JsonElement value;
|
||||
string text = (args.TryGetProperty("file_path", out value) ? (value.GetString() ?? "") : "");
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return ToolResult.Fail("file_path가 필요합니다.");
|
||||
}
|
||||
if (!Path.IsPathRooted(text) && !string.IsNullOrEmpty(context.WorkFolder))
|
||||
{
|
||||
text = Path.Combine(context.WorkFolder, text);
|
||||
}
|
||||
if (!File.Exists(text))
|
||||
{
|
||||
return ToolResult.Fail("파일 없음: " + text);
|
||||
}
|
||||
string text2 = Path.GetExtension(text).ToLowerInvariant();
|
||||
if (1 == 0)
|
||||
{
|
||||
}
|
||||
(string, string, string) tuple;
|
||||
switch (text2)
|
||||
{
|
||||
case ".cs":
|
||||
tuple = ("xUnit/NUnit/MSTest", ".cs", "ClassNameTests.cs");
|
||||
break;
|
||||
case ".py":
|
||||
tuple = ("pytest", ".py", "test_module.py");
|
||||
break;
|
||||
case ".ts":
|
||||
case ".tsx":
|
||||
tuple = ("Jest/Vitest", ".test.ts", "Component.test.ts");
|
||||
break;
|
||||
case ".js":
|
||||
case ".jsx":
|
||||
tuple = ("Jest", ".test.js", "module.test.js");
|
||||
break;
|
||||
case ".java":
|
||||
tuple = ("JUnit", ".java", "ClassTest.java");
|
||||
break;
|
||||
case ".go":
|
||||
tuple = ("go test", "_test.go", "module_test.go");
|
||||
break;
|
||||
default:
|
||||
tuple = ("unknown", text2, "test" + text2);
|
||||
break;
|
||||
}
|
||||
if (1 == 0)
|
||||
{
|
||||
}
|
||||
(string, string, string) tuple2 = tuple;
|
||||
string item = tuple2.Item1;
|
||||
string item2 = tuple2.Item2;
|
||||
string item3 = tuple2.Item3;
|
||||
string text3 = File.ReadAllText(text);
|
||||
int value2 = text3.Split('\n').Length;
|
||||
return ToolResult.Ok($"테스트 생성 제안:\n 대상 파일: {Path.GetFileName(text)} ({value2}줄)\n 감지된 프레임워크: {item}\n 테스트 파일 명명: {item3}\n 테스트 파일 확장자: {item2}\n\nfile_write 도구로 테스트 파일을 생성한 후, test_loop action=\"run\"으로 실행하세요.");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RunTestsAsync(AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
{
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
}
|
||||
var (cmd, cmdArgs) = DetectTestCommand(context.WorkFolder);
|
||||
if (cmd == null)
|
||||
{
|
||||
return ToolResult.Fail("테스트 프레임워크를 감지할 수 없습니다. 지원: .NET (dotnet test), Python (pytest), Node.js (npm test)");
|
||||
}
|
||||
try
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = cmd,
|
||||
Arguments = cmdArgs,
|
||||
WorkingDirectory = context.WorkFolder,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using Process proc = Process.Start(psi);
|
||||
if (proc == null)
|
||||
{
|
||||
return ToolResult.Fail("테스트 프로세스 시작 실패");
|
||||
}
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(120.0));
|
||||
string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
string stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
StringBuilder stringBuilder = sb;
|
||||
StringBuilder stringBuilder2 = stringBuilder;
|
||||
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder);
|
||||
handler.AppendLiteral("테스트 실행 결과 (exit code: ");
|
||||
handler.AppendFormatted(proc.ExitCode);
|
||||
handler.AppendLiteral("):");
|
||||
stringBuilder2.AppendLine(ref handler);
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder3 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 2, stringBuilder);
|
||||
handler.AppendLiteral("명령: ");
|
||||
handler.AppendFormatted(cmd);
|
||||
handler.AppendLiteral(" ");
|
||||
handler.AppendFormatted(cmdArgs);
|
||||
stringBuilder3.AppendLine(ref handler);
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
sb.AppendLine(stdout);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder4 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
|
||||
handler.AppendLiteral("[STDERR]\n");
|
||||
handler.AppendFormatted(stderr);
|
||||
stringBuilder4.AppendLine(ref handler);
|
||||
}
|
||||
return (proc.ExitCode == 0) ? ToolResult.Ok(sb.ToString()) : ToolResult.Ok($"테스트 실패 (exit code {proc.ExitCode}):\n{sb}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail("테스트 실행 오류: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult AnalyzeTestOutput(JsonElement args)
|
||||
{
|
||||
JsonElement value;
|
||||
string text = (args.TryGetProperty("test_output", out value) ? (value.GetString() ?? "") : "");
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return ToolResult.Fail("test_output이 필요합니다.");
|
||||
}
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
stringBuilder.AppendLine("테스트 결과 분석:");
|
||||
string[] source = text.Split('\n');
|
||||
int num = source.Count((string l) => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED"));
|
||||
int value2 = source.Count((string l) => l.Contains("PASS", StringComparison.OrdinalIgnoreCase) || l.Contains("PASSED"));
|
||||
List<string> list = source.Where((string l) => l.Contains("Error", StringComparison.OrdinalIgnoreCase) || l.Contains("Exception")).Take(10).ToList();
|
||||
StringBuilder stringBuilder2 = stringBuilder;
|
||||
StringBuilder stringBuilder3 = stringBuilder2;
|
||||
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
|
||||
handler.AppendLiteral(" 통과: ");
|
||||
handler.AppendFormatted(value2);
|
||||
handler.AppendLiteral("개, 실패: ");
|
||||
handler.AppendFormatted(num);
|
||||
handler.AppendLiteral("개");
|
||||
stringBuilder3.AppendLine(ref handler);
|
||||
if (list.Count > 0)
|
||||
{
|
||||
stringBuilder.AppendLine("\n주요 오류:");
|
||||
foreach (string item in list)
|
||||
{
|
||||
stringBuilder2 = stringBuilder;
|
||||
StringBuilder stringBuilder4 = stringBuilder2;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
|
||||
handler.AppendLiteral(" ");
|
||||
handler.AppendFormatted(item.Trim());
|
||||
stringBuilder4.AppendLine(ref handler);
|
||||
}
|
||||
}
|
||||
if (num > 0)
|
||||
{
|
||||
stringBuilder.AppendLine("\n다음 단계: 실패한 테스트를 확인하고 관련 코드를 수정한 후 test_loop action=\"run\"으로 다시 실행하세요.");
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.AppendLine("\n모든 테스트가 통과했습니다.");
|
||||
}
|
||||
return ToolResult.Ok(stringBuilder.ToString());
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> AutoFixAsync(AgentContext context, CancellationToken ct)
|
||||
{
|
||||
ToolResult runResult = await RunTestsAsync(context, ct);
|
||||
string output = runResult.Output;
|
||||
string[] lines = output.Split('\n');
|
||||
int failedCount = lines.Count((string l) => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED", StringComparison.OrdinalIgnoreCase));
|
||||
if (failedCount == 0 && runResult.Success && output.Contains("exit code: 0"))
|
||||
{
|
||||
return ToolResult.Ok("[AUTO_FIX: ALL_PASSED]\n모든 테스트가 통과했습니다. 수정 루프를 종료하세요.");
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("[AUTO_FIX: FAILURES_DETECTED]");
|
||||
StringBuilder stringBuilder = sb;
|
||||
StringBuilder stringBuilder2 = stringBuilder;
|
||||
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder);
|
||||
handler.AppendLiteral("실패 테스트 수: ");
|
||||
handler.AppendFormatted(failedCount);
|
||||
stringBuilder2.AppendLine(ref handler);
|
||||
sb.AppendLine();
|
||||
List<FailureDetail> errors = ExtractFailureDetails(lines);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## 실패 상세:");
|
||||
foreach (FailureDetail err in errors.Take(10))
|
||||
{
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder3 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
|
||||
handler.AppendLiteral("- 테스트: ");
|
||||
handler.AppendFormatted(err.TestName);
|
||||
stringBuilder3.AppendLine(ref handler);
|
||||
if (!string.IsNullOrEmpty(err.FilePath))
|
||||
{
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder4 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder);
|
||||
handler.AppendLiteral(" 파일: ");
|
||||
handler.AppendFormatted(err.FilePath);
|
||||
handler.AppendLiteral(":");
|
||||
handler.AppendFormatted(err.Line);
|
||||
stringBuilder4.AppendLine(ref handler);
|
||||
}
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder5 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
|
||||
handler.AppendLiteral(" 오류: ");
|
||||
handler.AppendFormatted(err.Message);
|
||||
stringBuilder5.AppendLine(ref handler);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
sb.AppendLine("## 수정 지침:");
|
||||
sb.AppendLine("1. 위 오류 메시지에서 원인을 파악하세요");
|
||||
sb.AppendLine("2. file_read로 관련 파일을 읽고 오류 원인을 확인하세요");
|
||||
sb.AppendLine("3. file_edit로 코드를 수정하세요");
|
||||
sb.AppendLine("4. test_loop action=\"auto_fix\"를 다시 호출하여 결과를 확인하세요");
|
||||
sb.AppendLine("5. 모든 테스트가 통과할 때까지 반복하세요");
|
||||
int maxIter = (Application.Current as App)?.SettingsService?.Settings.Llm.MaxTestFixIterations ?? 5;
|
||||
stringBuilder = sb;
|
||||
StringBuilder stringBuilder6 = stringBuilder;
|
||||
handler = new StringBuilder.AppendInterpolatedStringHandler(36, 1, stringBuilder);
|
||||
handler.AppendLiteral("\n※ 최대 수정 반복 횟수: ");
|
||||
handler.AppendFormatted(maxIter);
|
||||
handler.AppendLiteral("회. 초과 시 사용자에게 보고하세요.");
|
||||
stringBuilder6.AppendLine(ref handler);
|
||||
sb.AppendLine("\n## 전체 테스트 출력:");
|
||||
string truncated = ((output.Length > 3000) ? (output.Substring(0, 3000) + "\n... (출력 일부 생략)") : output);
|
||||
sb.AppendLine(truncated);
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static List<FailureDetail> ExtractFailureDetails(string[] lines)
|
||||
{
|
||||
List<FailureDetail> list = new List<FailureDetail>();
|
||||
FailureDetail failureDetail = null;
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
string text = lines[i].Trim();
|
||||
if (text.StartsWith("Failed ", StringComparison.OrdinalIgnoreCase) || text.Contains("FAIL!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failureDetail = new FailureDetail
|
||||
{
|
||||
TestName = text
|
||||
};
|
||||
list.Add(failureDetail);
|
||||
}
|
||||
else if (text.StartsWith("FAILED ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
FailureDetail failureDetail2 = new FailureDetail();
|
||||
string text2 = text;
|
||||
failureDetail2.TestName = text2.Substring(7, text2.Length - 7).Trim();
|
||||
failureDetail = failureDetail2;
|
||||
list.Add(failureDetail);
|
||||
}
|
||||
else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.FilePath))
|
||||
{
|
||||
Match match = Regex.Match(text, "([^\\s]+\\.\\w+)[:\\(](\\d+)");
|
||||
if (match.Success)
|
||||
{
|
||||
failureDetail.FilePath = match.Groups[1].Value;
|
||||
failureDetail.Line = int.Parse(match.Groups[2].Value);
|
||||
}
|
||||
}
|
||||
else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.Message) && (text.Contains("Assert", StringComparison.OrdinalIgnoreCase) || text.Contains("Error", StringComparison.OrdinalIgnoreCase) || text.Contains("Exception", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
failureDetail.Message = text;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static (string? Cmd, string Args) DetectTestCommand(string workFolder)
|
||||
{
|
||||
if (Directory.EnumerateFiles(workFolder, "*.csproj", SearchOption.AllDirectories).Any() || Directory.EnumerateFiles(workFolder, "*.sln", SearchOption.TopDirectoryOnly).Any())
|
||||
{
|
||||
return (Cmd: "dotnet", Args: "test --no-build --verbosity normal");
|
||||
}
|
||||
if (File.Exists(Path.Combine(workFolder, "pytest.ini")) || File.Exists(Path.Combine(workFolder, "setup.py")) || Directory.EnumerateFiles(workFolder, "test_*.py", SearchOption.AllDirectories).Any())
|
||||
{
|
||||
return (Cmd: "pytest", Args: "--tb=short -q");
|
||||
}
|
||||
if (File.Exists(Path.Combine(workFolder, "package.json")))
|
||||
{
|
||||
return (Cmd: "npm", Args: "test -- --passWithNoTests");
|
||||
}
|
||||
if (Directory.EnumerateFiles(workFolder, "*_test.go", SearchOption.AllDirectories).Any())
|
||||
{
|
||||
return (Cmd: "go", Args: "test ./...");
|
||||
}
|
||||
return (Cmd: null, Args: "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user