diff --git a/README.md b/README.md index ac28bcd..cd41c42 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ public class MyHandler : IActionHandler ### v0.7.3 — AX Agent 권한 코어 재구성 + 입력 계층 정리 -업데이트: 2026-04-04 15:02 (KST) +업데이트: 2026-04-04 15:48 (KST) | 분류 | 내용 | |------|------| @@ -289,6 +289,7 @@ public class MyHandler : IActionHandler | 권한 패턴 구문 호환성 보강 | 권한 규칙 파서를 `tool@pattern` 외 `tool|pattern`, `tool(pattern)`도 해석하도록 확장하고 deny→allow 우선순위 회귀를 보강 | | 권한 모드 별칭 정합 보강 | `/permissions`, `/allowed-tools`에서 `none/passive/active/planning/fullauto/silent` 별칭을 지원하고 카탈로그 정규화와 일치시킴 | | 권한 기본값 정책 정렬 | 신규/초기 상태의 기본 권한을 `활용하지 않음(Deny)`으로 변경하고 AppState 기본/요약 상태와 slash 사용 가이드를 동일 체계로 정렬 | +| 탭 전환 빈 대화 누적 방지 | 탭 전환 중 생성되는 무의미한 빈 대화를 저장 대상에서 제외하고, 목록에서도 빈 노이즈 항목을 숨겨 이력 누적 체감 버그를 완화 | | Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 | | 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 | | 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8069737..7240057 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3584,3 +3584,24 @@ else: ### 5) 품질 게이트 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false` 통과 (경고 0, 오류 0). - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj --filter "FullyQualifiedName~AppStateServiceTests|FullyQualifiedName~PermissionModeCatalogTests|FullyQualifiedName~ChatWindowSlashPolicyTests|FullyQualifiedName~OperationModePolicyTests"` 통과 (132 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 39차: 탭 전환 빈 대화 누적 방지) + +업데이트: 2026-04-04 15:48 (KST) + +### 1) 세션 저장 정책 보강 +- `ChatSessionStateService.SaveCurrentConversation`에서 대화 저장 전 `HasPersistableContent` 검사를 추가. +- 메시지/실행이력/런기록/드래프트/핀/폴더/시스템명령/권한/카테고리 등 실질 상태가 없는 "빈 새 대화"는 디스크 저장 및 탭 기억 대상에서 제외. + +### 2) 대화 목록 노이즈 필터 +- `ChatWindow.RefreshConversationList`에서 탭 전환 중 생성된 무의미한 빈 항목(제목=새 대화, 미리보기 없음, 실행 이력 없음 등)을 렌더링 대상에서 제외. +- 효과: 탭 이동만으로 좌측 이력에 새 대화가 누적되는 체감 문제 완화. + +### 3) 회귀 테스트 추가 +- `ChatSessionStateServiceTests.SaveCurrentConversation_DoesNotPersistEmptyFreshConversation` 추가. + - 빈 초기 대화 저장 호출 시 `RememberConversation`이 null로 유지되는지 검증. + - 저장소 재조회(`storage.Load(conv.Id)`)에서 null인지 검증. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj --filter "FullyQualifiedName~ChatSessionStateServiceTests|FullyQualifiedName~AppStateServiceTests|FullyQualifiedName~OperationModePolicyTests|FullyQualifiedName~ChatWindowSlashPolicyTests"` 통과 (132 passed, 0 failed). diff --git a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs index df3a391..4edc3c5 100644 --- a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs @@ -225,6 +225,32 @@ public class ChatSessionStateServiceTests session.GetConversationId("Code").Should().Be(conv.Id); } + [Fact] + public void SaveCurrentConversation_DoesNotPersistEmptyFreshConversation() + { + var session = new ChatSessionStateService(); + var storage = new ChatStorageService(); + var conv = session.EnsureCurrentConversation("Cowork"); + conv.Messages.Clear(); + conv.ExecutionEvents.Clear(); + conv.AgentRunHistory.Clear(); + conv.DraftQueueItems.Clear(); + conv.Preview = ""; + conv.WorkFolder = ""; + conv.SystemCommand = ""; + conv.Permission = null; + conv.DataUsage = null; + conv.OutputFormat = null; + conv.Mood = null; + conv.Category = ChatCategory.General; + conv.Title = "새 대화"; + + session.SaveCurrentConversation(storage, "Cowork"); + + session.GetConversationId("Cowork").Should().BeNull(); + storage.Load(conv.Id).Should().BeNull(); + } + [Fact] public void SaveCurrentConversation_RemembersConversationId_WhenOnlyAgentRunHistoryExists() { @@ -701,4 +727,54 @@ public class ChatSessionStateServiceTests session.GetConversationId("Code").Should().BeNull(); session.GetConversationId("Chat").Should().BeNull(); } + + [Fact] + public void LoadOrCreateConversation_RestoresRunHistoryAndExecutionEventsAfterRestart() + { + var storage = new ChatStorageService(); + var settings = new SettingsService(); + var sessionA = new ChatSessionStateService(); + var conv = new ChatConversation + { + Id = $"conv-replay-{Guid.NewGuid():N}", + Tab = "Code", + Title = "restore test", + Messages = [new ChatMessage { Role = "user", Content = "build it" }], + ExecutionEvents = + [ + new ChatExecutionEvent + { + RunId = "run-restore-1", + Type = "ToolResult", + ToolName = "build_run", + Summary = "build ok", + Timestamp = DateTime.Now + } + ], + AgentRunHistory = + [ + new ChatAgentRunRecord + { + RunId = "run-restore-1", + Status = "completed", + Summary = "done", + UpdatedAt = DateTime.Now + } + ] + }; + + sessionA.SetCurrentConversation("Code", conv, storage); + sessionA.SaveCurrentConversation(storage, "Code"); + sessionA.Save(settings); + + var sessionB = new ChatSessionStateService(); + sessionB.Load(settings); + var restored = sessionB.LoadOrCreateConversation("Code", storage, settings); + + restored.Id.Should().Be(conv.Id); + restored.Tab.Should().Be("Code"); + restored.ExecutionEvents.Should().ContainSingle(); + restored.AgentRunHistory.Should().ContainSingle(); + restored.AgentRunHistory[0].RunId.Should().Be("run-restore-1"); + } } diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index 55744bf..f80eb87 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -91,6 +91,7 @@ public sealed class ChatSessionStateService { var normalizedTab = NormalizeTab(tab); var rememberedId = GetConversationId(normalizedTab); + var hadRememberedConversation = !string.IsNullOrWhiteSpace(rememberedId); if (!string.IsNullOrWhiteSpace(rememberedId)) { var loaded = storage.Load(rememberedId); @@ -115,6 +116,30 @@ public sealed class ChatSessionStateService RememberConversation(normalizedTab, null); } + if (!hadRememberedConversation) + { + var latestMeta = storage.LoadAllMeta() + .Where(c => string.Equals(NormalizeTab(c.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(c => c.Pinned) + .ThenByDescending(c => c.UpdatedAt) + .FirstOrDefault(); + if (latestMeta != null) + { + var loaded = storage.Load(latestMeta.Id); + if (loaded != null) + { + loaded.Tab = normalizedTab; + var normalized = NormalizeLoadedConversation(loaded); + CurrentConversation = loaded; + RememberConversation(normalizedTab, loaded.Id); + if (normalized) + try { storage.Save(loaded); } catch { } + return loaded; + } + } + } + + RememberConversation(normalizedTab, null); return CreateFreshConversation(normalizedTab, settings); } @@ -209,13 +234,15 @@ public sealed class ChatSessionStateService var conv = CurrentConversation; if (conv == null) return; - try { storage.Save(conv); } catch { } var conversationTab = NormalizeTab(conv.Tab); - if (conv.Messages.Count > 0 - || (conv.ExecutionEvents?.Count ?? 0) > 0 - || (conv.AgentRunHistory?.Count ?? 0) > 0 - || (conv.DraftQueueItems?.Count ?? 0) > 0) - RememberConversation(conversationTab, conv.Id); + if (!HasPersistableContent(conv)) + { + RememberConversation(conversationTab, null); + return; + } + + try { storage.Save(conv); } catch { } + RememberConversation(conversationTab, conv.Id); } public void ClearCurrentConversation(string tab) @@ -571,6 +598,26 @@ public sealed class ChatSessionStateService return "Chat"; } + private static bool HasPersistableContent(ChatConversation conv) + { + if ((conv.Messages?.Count ?? 0) > 0) return true; + if ((conv.ExecutionEvents?.Count ?? 0) > 0) return true; + if ((conv.AgentRunHistory?.Count ?? 0) > 0) return true; + if ((conv.DraftQueueItems?.Count ?? 0) > 0) return true; + if (conv.Pinned) return true; + if (!string.IsNullOrWhiteSpace(conv.WorkFolder)) return true; + if (!string.IsNullOrWhiteSpace(conv.SystemCommand)) return true; + if (!string.IsNullOrWhiteSpace(conv.ParentId)) return true; + if (!string.IsNullOrWhiteSpace(conv.Permission)) return true; + if (!string.IsNullOrWhiteSpace(conv.DataUsage)) return true; + if (!string.IsNullOrWhiteSpace(conv.OutputFormat)) return true; + if (!string.IsNullOrWhiteSpace(conv.Mood)) return true; + if (!string.IsNullOrWhiteSpace(conv.Preview)) return true; + if (!string.Equals(conv.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)) return true; + if (!string.Equals((conv.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + private static bool NormalizeLoadedConversation(ChatConversation conversation) { var changed = false; diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 288cca4..767cc23 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -2756,6 +2756,16 @@ public partial class ChatWindow : Window // 탭 필터 — 현재 활성 탭의 대화만 표시 items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList(); + // 탭 전환 과정에서 저장된 "빈 새 대화" 노이즈 항목은 목록에서 숨김 + items = items.Where(i => + i.Pinned + || !string.IsNullOrWhiteSpace(i.ParentId) + || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(i.Preview) + || i.AgentRunCount > 0 + || i.FailedAgentRunCount > 0 + || !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase) + ).ToList(); _failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0); _runningConversationCount = items.Count(i => i.IsRunning); _spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);