탭 전환 빈 대화 누적 방지: 저장 게이트 + 목록 노이즈 필터
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatSessionStateService.SaveCurrentConversation에 persistable-content 검사 추가 - 무의미한 빈 새 대화는 저장/탭 기억 대상에서 제외 - ChatWindow 대화 목록에서 빈 노이즈 항목 필터링 - ChatSessionStateServiceTests 회귀 추가 및 문서 이력(2026-04-04 15:48 KST) 동기화
This commit is contained in:
@@ -222,7 +222,7 @@ public class MyHandler : IActionHandler
|
|||||||
|
|
||||||
### v0.7.3 — AX Agent 권한 코어 재구성 + 입력 계층 정리
|
### 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 우선순위 회귀를 보강 |
|
| 권한 패턴 구문 호환성 보강 | 권한 규칙 파서를 `tool@pattern` 외 `tool|pattern`, `tool(pattern)`도 해석하도록 확장하고 deny→allow 우선순위 회귀를 보강 |
|
||||||
| 권한 모드 별칭 정합 보강 | `/permissions`, `/allowed-tools`에서 `none/passive/active/planning/fullauto/silent` 별칭을 지원하고 카탈로그 정규화와 일치시킴 |
|
| 권한 모드 별칭 정합 보강 | `/permissions`, `/allowed-tools`에서 `none/passive/active/planning/fullauto/silent` 별칭을 지원하고 카탈로그 정규화와 일치시킴 |
|
||||||
| 권한 기본값 정책 정렬 | 신규/초기 상태의 기본 권한을 `활용하지 않음(Deny)`으로 변경하고 AppState 기본/요약 상태와 slash 사용 가이드를 동일 체계로 정렬 |
|
| 권한 기본값 정책 정렬 | 신규/초기 상태의 기본 권한을 `활용하지 않음(Deny)`으로 변경하고 AppState 기본/요약 상태와 slash 사용 가이드를 동일 체계로 정렬 |
|
||||||
|
| 탭 전환 빈 대화 누적 방지 | 탭 전환 중 생성되는 무의미한 빈 대화를 저장 대상에서 제외하고, 목록에서도 빈 노이즈 항목을 숨겨 이력 누적 체감 버그를 완화 |
|
||||||
| Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 |
|
| Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 |
|
||||||
| 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 |
|
| 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 |
|
||||||
| 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed |
|
| 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed |
|
||||||
|
|||||||
@@ -3584,3 +3584,24 @@ else:
|
|||||||
### 5) 품질 게이트
|
### 5) 품질 게이트
|
||||||
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false` 통과 (경고 0, 오류 0).
|
- `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).
|
- `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).
|
||||||
|
|||||||
@@ -225,6 +225,32 @@ public class ChatSessionStateServiceTests
|
|||||||
session.GetConversationId("Code").Should().Be(conv.Id);
|
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]
|
[Fact]
|
||||||
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyAgentRunHistoryExists()
|
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyAgentRunHistoryExists()
|
||||||
{
|
{
|
||||||
@@ -701,4 +727,54 @@ public class ChatSessionStateServiceTests
|
|||||||
session.GetConversationId("Code").Should().BeNull();
|
session.GetConversationId("Code").Should().BeNull();
|
||||||
session.GetConversationId("Chat").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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ public sealed class ChatSessionStateService
|
|||||||
{
|
{
|
||||||
var normalizedTab = NormalizeTab(tab);
|
var normalizedTab = NormalizeTab(tab);
|
||||||
var rememberedId = GetConversationId(normalizedTab);
|
var rememberedId = GetConversationId(normalizedTab);
|
||||||
|
var hadRememberedConversation = !string.IsNullOrWhiteSpace(rememberedId);
|
||||||
if (!string.IsNullOrWhiteSpace(rememberedId))
|
if (!string.IsNullOrWhiteSpace(rememberedId))
|
||||||
{
|
{
|
||||||
var loaded = storage.Load(rememberedId);
|
var loaded = storage.Load(rememberedId);
|
||||||
@@ -115,6 +116,30 @@ public sealed class ChatSessionStateService
|
|||||||
RememberConversation(normalizedTab, null);
|
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);
|
return CreateFreshConversation(normalizedTab, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,13 +234,15 @@ public sealed class ChatSessionStateService
|
|||||||
var conv = CurrentConversation;
|
var conv = CurrentConversation;
|
||||||
if (conv == null) return;
|
if (conv == null) return;
|
||||||
|
|
||||||
try { storage.Save(conv); } catch { }
|
|
||||||
var conversationTab = NormalizeTab(conv.Tab);
|
var conversationTab = NormalizeTab(conv.Tab);
|
||||||
if (conv.Messages.Count > 0
|
if (!HasPersistableContent(conv))
|
||||||
|| (conv.ExecutionEvents?.Count ?? 0) > 0
|
{
|
||||||
|| (conv.AgentRunHistory?.Count ?? 0) > 0
|
RememberConversation(conversationTab, null);
|
||||||
|| (conv.DraftQueueItems?.Count ?? 0) > 0)
|
return;
|
||||||
RememberConversation(conversationTab, conv.Id);
|
}
|
||||||
|
|
||||||
|
try { storage.Save(conv); } catch { }
|
||||||
|
RememberConversation(conversationTab, conv.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearCurrentConversation(string tab)
|
public void ClearCurrentConversation(string tab)
|
||||||
@@ -571,6 +598,26 @@ public sealed class ChatSessionStateService
|
|||||||
return "Chat";
|
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)
|
private static bool NormalizeLoadedConversation(ChatConversation conversation)
|
||||||
{
|
{
|
||||||
var changed = false;
|
var changed = false;
|
||||||
|
|||||||
@@ -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 => 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);
|
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
|
||||||
_runningConversationCount = items.Count(i => i.IsRunning);
|
_runningConversationCount = items.Count(i => i.IsRunning);
|
||||||
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
|
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
|
||||||
|
|||||||
Reference in New Issue
Block a user