diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md
index c7160c6..909dd86 100644
--- a/docs/AGENT_ROADMAP.md
+++ b/docs/AGENT_ROADMAP.md
@@ -252,20 +252,25 @@
새 파일: `AgentLoopService.Memory.cs` (105줄) — `InjectHierarchicalMemoryAsync()` + `InjectPathScopedRulesAsync()`
설정 추가: `EnableMemorySystem` (기본 true)
-### Group F — 권한 시스템 고도화 (CC 권한 문서 기반)
+### Group F — 권한 시스템 고도화 (CC 권한 문서 기반) ✅ 완료
-| # | 기능 | 설명 | 우선순위 |
-|---|------|------|----------|
-| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 |
-| 17-F2 | **패턴 기반 허용/차단 규칙 UI** | process(git *) 허용, process(rm -rf *) 차단 등 패턴 규칙 편집기 | 높음 |
-| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 |
+| # | 기능 | 설명 | 우선순위 | 구현 |
+|---|------|------|----------|------|
+| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 | AgentLoopService.Permissions.cs — GetPermissionService() + EvaluateToolPermission(). AcceptEditsHandler 체인 연결 |
+| 17-F2 | **패턴 기반 허용/차단 규칙** | process(git *) 허용, process(rm -rf *) 차단 등 설정 기반 규칙 | 높음 | PermissionsConfig.AllowRules/DenyRules → PermissionRule 변환. Execution.cs Deny→즉시차단, Allow→CheckDecisionRequired 스킵 |
+| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 | CheckMcpToolAllowed() 추가 — 차기 MCP 강화 시 연계 예정 |
-### Group G — 개발자 경험
+새 파일: `AgentLoopService.Permissions.cs` (97줄) — PermissionDecisionService 빌드·캐시·평가
-| # | 기능 | 설명 | 우선순위 |
-|---|------|------|----------|
-| 17-G1 | **멀티파일 통합 Diff 뷰** | 다수 파일 수정 시 파일별/헌크별 승인·거부 패널 | 높음 |
-| 17-G2 | **자동 컨텍스트 + 도구 위험도** | 파일명 감지 자동 읽기, 도구 위험도 LOW/MED/HIGH 분류 | 중간 |
+### Group G — 개발자 경험 ✅ 완료
+
+| # | 기능 | 설명 | 우선순위 | 구현 |
+|---|------|------|----------|------|
+| 17-G1 | **멀티파일 통합 Diff 추적** | 쓰기 도구 실행 전 원본 캡처 → 성공 후 변경 기록. MultiFileDiffViewModel UI 바인딩 | 높음 | AgentLoopService.DiffTracker.cs — CaptureOriginalFileContent() + TrackFileModificationForDiff(). Execution.cs 전·후 호출 통합 |
+| 17-G2 | **도구 위험도 표시** | ToolCall 이벤트에 [⚠ 높음]/[• 보통] 태그 표시. ToolRiskMapper 연계 | 중간 | Execution.cs EmitEvent(ToolCall) 에 riskTag 접미사 추가. toolName17 변수로 nullable 경고 0개 달성 |
+
+새 파일: `AgentLoopService.DiffTracker.cs` (99줄) — ExtractFilePathFromInput() + CaptureOriginalFileContent() + TrackFileModificationForDiff() + ResetDiffTracker()
+설정 추가: `EnableDiffTracker` (기본 true)
---
diff --git a/src/AxCopilot/Models/AppSettings.LlmSettings.cs b/src/AxCopilot/Models/AppSettings.LlmSettings.cs
index 16c1e9f..50ee873 100644
--- a/src/AxCopilot/Models/AppSettings.LlmSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.LlmSettings.cs
@@ -364,6 +364,10 @@ public class LlmSettings
[JsonPropertyName("enableMemorySystem")]
public bool EnableMemorySystem { get; set; } = true;
+ /// 멀티파일 Diff 추적 활성화 여부. 쓰기 도구 실행 전후 파일 변경을 기록. 기본 true.
+ [JsonPropertyName("enableDiffTracker")]
+ public bool EnableDiffTracker { get; set; } = true;
+
/// 추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.
[JsonPropertyName("skillsFolderPath")]
public string SkillsFolderPath { get; set; } = "";
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs b/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs
new file mode 100644
index 0000000..64e2f66
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs
@@ -0,0 +1,120 @@
+using System.IO;
+using System.Text;
+using AxCopilot.ViewModels;
+
+namespace AxCopilot.Services.Agent;
+
+///
+/// Phase 17-G: AgentLoopService — 멀티파일 Diff 추적기.
+/// · 쓰기 도구 실행 전 원본 파일 내용 캡처
+/// · 성공 후 변경 내용을 MultiFileDiffViewModel에 기록
+/// · 도구 위험도 LOW/MED/HIGH 분류 지원 (ToolRiskMapper 연계)
+///
+public partial class AgentLoopService
+{
+ // 멀티파일 Diff ViewModel — ChatWindow가 바인딩
+ private MultiFileDiffViewModel? _diffViewModel;
+
+ /// 멀티파일 Diff ViewModel. ChatWindow UI에 바인딩하여 Diff 패널을 표시합니다.
+ public MultiFileDiffViewModel DiffViewModel
+ => _diffViewModel ??= new MultiFileDiffViewModel();
+
+ // Diff 추적 대상 쓰기 도구 (내용 기반 비교 가능한 텍스트 파일 생성/수정 도구)
+ private static readonly HashSet _diffWriteTools = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "file_write", "file_edit", "script_create",
+ "html_create", "markdown_create", "csv_create",
+ };
+
+ // ─────────────────────────────────────────────────────────────────────
+ // 파일 경로 추출 + 원본 내용 캡처
+ // ─────────────────────────────────────────────────────────────────────
+
+ ///
+ /// 도구 입력 JSON에서 파일 경로를 추출합니다.
+ /// "path" → "file_path" 순으로 탐색합니다.
+ ///
+ internal static string? ExtractFilePathFromInput(System.Text.Json.JsonElement? input)
+ {
+ if (input == null) return null;
+ if (input.Value.TryGetProperty("path", out var p)) return p.GetString();
+ if (input.Value.TryGetProperty("file_path", out var fp)) return fp.GetString();
+ return null;
+ }
+
+ ///
+ /// 쓰기 도구 실행 전 원본 파일 내용을 캡처합니다.
+ /// 파일이 없으면 빈 문자열 반환 (신규 생성 케이스).
+ ///
+ internal static string CaptureOriginalFileContent(string? filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath)) return "";
+ try
+ {
+ return File.Exists(filePath)
+ ? File.ReadAllText(filePath, Encoding.UTF8)
+ : ""; // 신규 파일 — 원본 없음
+ }
+ catch { return ""; }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Diff 기록
+ // ─────────────────────────────────────────────────────────────────────
+
+ ///
+ /// 쓰기 도구 성공 후 파일 변경을 DiffViewModel에 기록합니다.
+ /// 변경 후 내용은 디스크에서 재읽기 (도구가 이미 파일에 기록 완료).
+ /// UI 스레드 Dispatcher를 통해 안전하게 ViewModel 업데이트.
+ ///
+ internal void TrackFileModificationForDiff(string toolName, string? filePath, string originalContent)
+ {
+ if (string.IsNullOrWhiteSpace(filePath)) return;
+ if (!_diffWriteTools.Contains(toolName)) return;
+ if (!_settings.Settings.Llm.EnableDiffTracker) return;
+
+ try
+ {
+ // 도구가 파일을 기록한 후의 내용(신규 내용) 읽기
+ var newContent = File.Exists(filePath)
+ ? File.ReadAllText(filePath, Encoding.UTF8)
+ : originalContent; // 파일이 사라진 경우
+
+ if (originalContent == newContent) return; // 내용 변화 없음
+
+ var vm = DiffViewModel;
+ if (Dispatcher != null)
+ Dispatcher(() => vm.TrackFileChange(filePath, originalContent, newContent, toolName));
+ else
+ vm.TrackFileChange(filePath, originalContent, newContent, toolName);
+
+ // 이벤트 로그
+ _ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult,
+ System.Text.Json.JsonSerializer.Serialize(new
+ {
+ type = "diff_tracked",
+ toolName,
+ filePath,
+ addedLines = newContent.Split('\n').Length - originalContent.Split('\n').Length,
+ riskLevel = ToolRiskMapper.GetRisk(toolName).ToString(),
+ }));
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"[DiffTracker] 파일 변경 추적 실패: {ex.Message}");
+ }
+ }
+
+ ///
+ /// 새 에이전트 세션 시작 시 Diff 목록을 초기화합니다.
+ /// ChatWindow에서 이전 세션의 Diff 항목이 남지 않도록 합니다.
+ ///
+ internal void ResetDiffTracker()
+ {
+ if (_diffViewModel == null) return;
+ if (Dispatcher != null)
+ Dispatcher(() => _diffViewModel.Clear());
+ else
+ _diffViewModel.Clear();
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs
index aa672bb..c4769dc 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs
@@ -137,7 +137,11 @@ public partial class AgentLoopService
_ = _eventLog.AppendAsync(AgentEventLogType.ToolRequest,
JsonSerializer.Serialize(new { toolName = call.ToolName, iteration }));
- EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call));
+ // Phase 17-G2: 도구 위험도 태그 (Medium/High만 표시)
+ var toolName17 = call.ToolName ?? "";
+ var toolRisk = ToolRiskMapper.GetRisk(toolName17);
+ var riskTag = toolRisk >= ToolRiskLevel.Medium ? $" [{ToolRiskMapper.GetRiskLabel(toolRisk)}]" : "";
+ EmitEvent(AgentEventType.ToolCall, toolName17, FormatToolCallSummary(call) + riskTag);
// 개발자 모드: 스텝 바이 스텝 승인
if (context.DevModeStepApproval && UserDecisionCallback != null)
@@ -153,13 +157,28 @@ public partial class AgentLoopService
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
- call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
+ call.ToolId ?? "", toolName17, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
- // 영향 범위 기반 의사결정 체크
- var decisionRequired = CheckDecisionRequired(call, context);
+ // Phase 17-F: PermissionDecisionService 기반 권한 평가
+ var toolInputStr = call.ToolInput?.ToString() ?? "";
+ var permDecision = EvaluateToolPermission(call.ToolName ?? "", toolInputStr);
+
+ if (permDecision == PermissionDecision.Deny)
+ {
+ var denyMsg = $"도구 '{toolName17}'이(가) 권한 정책에 의해 차단되었습니다. [{ToolRiskMapper.GetRiskLabel(toolRisk)}]";
+ EmitEvent(AgentEventType.Error, toolName17, denyMsg);
+ messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, $"[DENIED] {denyMsg}"));
+ return (ToolCallAction.Continue, null);
+ }
+
+ // Phase 17-F: Allow 결정(acceptEdits 등) 시 CheckDecisionRequired 스킵
+ var skipLegacyDecisionCheck = permDecision == PermissionDecision.Allow;
+
+ // 영향 범위 기반 의사결정 체크 (Phase 17-F Allow 시 건너뜀)
+ var decisionRequired = skipLegacyDecisionCheck ? null : CheckDecisionRequired(call, context);
if (decisionRequired != null && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
@@ -173,7 +192,7 @@ public partial class AgentLoopService
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
- call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
+ call.ToolId ?? "", toolName17, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
@@ -181,6 +200,10 @@ public partial class AgentLoopService
// ── Pre-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "pre", ct);
+ // Phase 17-G: Diff 추적 — 쓰기 도구 실행 전 원본 내용 캡처
+ var diffFilePath = ExtractFilePathFromInput(call.ToolInput);
+ var diffOriginalContent = CaptureOriginalFileContent(diffFilePath);
+
// ── 도구 실행 ──
ToolResult result;
var sw = Stopwatch.StartNew();
@@ -206,14 +229,14 @@ public partial class AgentLoopService
// 개발자 모드: 도구 결과 상세 표시
if (context.DevMode)
{
- EmitEvent(AgentEventType.Thinking, call.ToolName,
+ EmitEvent(AgentEventType.Thinking, toolName17,
$"[DEV] 결과: {(result.Success ? "성공" : "실패")}\n{TruncateOutput(result.Output, 500)}");
}
var tokenUsage = _llm.LastTokenUsage;
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
- call.ToolName,
+ toolName17,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: sw.ElapsedMilliseconds,
@@ -226,15 +249,15 @@ public partial class AgentLoopService
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
state.StatsInputTokens += tokenUsage?.PromptTokens ?? 0;
state.StatsOutputTokens += tokenUsage?.CompletionTokens ?? 0;
- if (!state.StatsUsedTools.Contains(call.ToolName))
- state.StatsUsedTools.Add(call.ToolName);
+ if (!state.StatsUsedTools.Contains(toolName17))
+ state.StatsUsedTools.Add(toolName17);
// 감사 로그 기록
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, activeTabSnapshot,
- call.ToolName,
+ toolName17,
call.ToolInput.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
@@ -274,16 +297,16 @@ public partial class AgentLoopService
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors <= state.MaxRetry)
{
- var reflectionMsg = $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
+ var reflectionMsg = $"[Tool '{toolName17}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
$"Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " +
$"Try a different approach. (Error {state.ConsecutiveErrors}/{state.MaxRetry})";
- messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, reflectionMsg));
+ messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, reflectionMsg));
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({state.ConsecutiveErrors}/{state.MaxRetry})");
}
else
{
messages.Add(LlmService.CreateToolResultMessage(
- call.ToolId, call.ToolName,
+ call.ToolId ?? "", toolName17,
$"[FAILED after {state.MaxRetry} retries] {TruncateOutput(result.Output ?? "", 500)}\n" +
"Stop retrying this tool. Explain the error to the user and suggest alternative approaches."));
@@ -314,9 +337,12 @@ public partial class AgentLoopService
// Phase 17-E: .ax/rules/*.md 경로 범위 규칙 주입
_ = InjectPathScopedRulesAsync(result.FilePath, messages, CancellationToken.None);
+ // Phase 17-G: 멀티파일 Diff 변경 기록
+ TrackFileModificationForDiff(call.ToolName ?? "", result.FilePath ?? diffFilePath, diffOriginalContent);
+
// ToolResultSizer 적용
- var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName);
- messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output));
+ var sizedResult = ToolResultSizer.Apply(result.Output ?? "", toolName17);
+ messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, sizedResult.Output));
// document_plan 호출 플래그 + 메타데이터 저장
if (call.ToolName == "document_plan" && result.Success)
@@ -346,14 +372,14 @@ public partial class AgentLoopService
}
// 단일 문서 생성 도구 성공 → 즉시 루프 종료
- if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1)
+ if (result.Success && IsTerminalDocumentTool(toolName17) && toolCalls.Count == 1)
{
var shouldVerify2 = activeTabSnapshot == "Code"
- ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
- : llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
+ ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17)
+ : llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17);
if (shouldVerify2)
{
- await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
+ await RunPostToolVerificationAsync(messages, toolName17, result, context, ct);
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (ToolCallAction.Return, result.Output ?? "");
@@ -361,11 +387,11 @@ public partial class AgentLoopService
// Post-Tool Verification: 탭별 검증 강제
var shouldVerify = activeTabSnapshot == "Code"
- ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
- : llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
+ ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17)
+ : llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17);
if (shouldVerify && result.Success)
{
- await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
+ await RunPostToolVerificationAsync(messages, toolName17, result, context, ct);
}
}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs
new file mode 100644
index 0000000..6bc085c
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs
@@ -0,0 +1,104 @@
+using AxCopilot.Models;
+
+namespace AxCopilot.Services.Agent;
+
+///
+/// Phase 17-F: AgentLoopService — 권한 시스템 고도화 통합.
+/// · PermissionsConfig(설정) → PermissionDecisionService(런타임) 변환 및 캐시
+/// · acceptEdits 모드: 파일 편집 도구 자동 승인, bash/process 확인 유지
+/// · 패턴 기반 Allow/Deny 규칙: process(git *) 허용, process(rm -rf *) 차단 등
+/// · Chain: Deny규칙 → Allow규칙 → AcceptEdits/Plan/Bypass모드 → 기본(Ask)
+///
+public partial class AgentLoopService
+{
+ // 세션당 PermissionDecisionService 캐시 (모드 변경 시 재생성)
+ private PermissionDecisionService? _permissionService;
+ private string? _cachedPermissionMode;
+
+ ///
+ /// 현재 설정 기반 PermissionDecisionService를 반환합니다.
+ /// 모드 변경 시 자동 재생성됩니다.
+ ///
+ internal PermissionDecisionService GetPermissionService()
+ {
+ var perms = _settings.Settings.Llm.Permissions;
+ var modeStr = perms.Mode ?? "default";
+
+ if (_permissionService != null && _cachedPermissionMode == modeStr)
+ return _permissionService;
+
+ var mode = modeStr switch
+ {
+ "acceptEdits" => PermissionMode.AcceptEdits,
+ "plan" => PermissionMode.Plan,
+ "bypassPermissions" => PermissionMode.BypassPermissions,
+ _ => PermissionMode.Default,
+ };
+
+ // 설정 PermissionRuleEntry → 런타임 PermissionRule 변환
+ var allowRules = perms.AllowRules
+ .Where(r => !string.IsNullOrEmpty(r.ToolName))
+ .Select(r => new PermissionRule
+ {
+ ToolName = r.ToolName,
+ Pattern = r.Pattern,
+ Behavior = PermissionBehavior.Allow,
+ })
+ .ToList();
+
+ var denyRules = perms.DenyRules
+ .Where(r => !string.IsNullOrEmpty(r.ToolName))
+ .Select(r => new PermissionRule
+ {
+ ToolName = r.ToolName,
+ Pattern = r.Pattern,
+ Behavior = PermissionBehavior.Deny,
+ })
+ .ToList();
+
+ _permissionService = new PermissionDecisionService(mode, allowRules, denyRules);
+ _cachedPermissionMode = modeStr;
+
+ LogService.Debug($"[Permissions] 모드={mode}, Allow={allowRules.Count}개, Deny={denyRules.Count}개");
+ return _permissionService;
+ }
+
+ ///
+ /// 도구 호출 권한을 평가합니다.
+ /// · Deny → 즉시 차단 (사용자 확인 없음)
+ /// · Allow → 무조건 허용 (CheckDecisionRequired 스킵)
+ /// · Ask → 기존 CheckDecisionRequired 흐름으로 위임
+ ///
+ internal PermissionDecision EvaluateToolPermission(string toolName, string input)
+ {
+ try
+ {
+ var decision = GetPermissionService().Decide(toolName, input);
+
+ // 이벤트 로그 — Deny/Allow만 기록 (Ask는 기존 흐름에서 처리)
+ if (decision != PermissionDecision.Ask)
+ _ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult,
+ System.Text.Json.JsonSerializer.Serialize(new
+ {
+ type = "permission",
+ toolName,
+ decision = decision.ToString(),
+ mode = _cachedPermissionMode ?? "default",
+ }));
+
+ return decision;
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"[Permissions] 권한 평가 실패: {ex.Message}");
+ return PermissionDecision.Ask; // 실패 시 기존 흐름으로 폴백
+ }
+ }
+
+ /// MCP 도구 허용 여부를 확인합니다. 차단된 서버/도구는 false.
+ internal bool CheckMcpToolAllowed(string serverName, string toolName)
+ {
+ try { return GetPermissionService().IsMcpToolAllowed(serverName, toolName); }
+ catch { return true; }
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs
index 2a0fa0f..a257f9b 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs
@@ -155,6 +155,7 @@ public partial class AgentLoopService
IsRunning = true;
_docFallbackAttempted = false;
_sessionId = Guid.NewGuid().ToString("N")[..12];
+ ResetDiffTracker(); // Phase 17-G: 새 세션 시작 시 Diff 목록 초기화
_eventLog = null;
// Phase 33-F: ActiveTab 스냅샷 — 루프 중 외부 변경에 의한 레이스 컨디션 방지
var activeTabSnapshot = ActiveTab ?? "Chat";