From 22261579d01f43ba14861e97fa41c3054f930d82 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 20:00:54 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20=EC=A4=91=20=ED=83=AD=20=EC=A0=84=ED=99=98=EA=B3=BC?= =?UTF-8?q?=20=EC=83=88=20=EB=8C=80=ED=99=94=EA=B0=80=20=EB=A9=88=EC=B6=94?= =?UTF-8?q?=EB=8A=94=20=EC=A0=80=EC=9E=A5=20=EB=A3=A8=ED=94=84=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인: 같은 LastActiveTab·LastConversationIds 상태에서도 ChatSessionStateService.Save가 매번 settings.Save를 호출해 SettingsChanged -> UpdateTabUI -> SwitchToTabConversation -> SaveLastConversations 순환이 계속 발생했습니다. 이 때문에 시간 표시가 0초에 머무르고 탭 전환/새 대화가 즉시 덮어써졌습니다. 수정: ChatSessionStateService는 세션 스냅샷이 실제로 바뀐 경우에만 저장하도록 변경했고, 동일 상태 반복 저장 시 Save와 SettingsChanged가 재발화하지 않도록 테스트를 추가했습니다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_streaming_tab_loop_fix\ -p:IntermediateOutputPath=obj\verify_streaming_tab_loop_fix\ (경고 0 / 오류 0), dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatSessionStateServiceTests|ChatStreamingUiPolicyTests|AxAgentExecutionEngineTests -p:OutputPath=bin\verify_streaming_tab_loop_fix_tests\ -p:IntermediateOutputPath=obj\verify_streaming_tab_loop_fix_tests\ (통과 51) --- README.md | 5 ++ docs/DEVELOPMENT.md | 6 ++ .../Services/ChatSessionStateServiceTests.cs | 77 +++++++++++++++++++ .../Services/ChatSessionStateService.cs | 39 +++++++++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index be8eca1..e5e5d53 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # AX Commander +- 업데이트: 2026-04-15 19:59 (KST) +- AX Agent 스트리밍 중 탭 전환, 새 대화, 경과 시간이 멈춘 것처럼 보이던 회귀를 수정했습니다. `src/AxCopilot/Services/ChatSessionStateService.cs`는 탭별 마지막 대화 ID와 활성 탭이 실제로 바뀐 경우에만 설정 저장을 수행합니다. +- 원인은 스트리밍 UI가 현재 대화를 다시 그릴 때마다 같은 세션 상태를 반복 저장하고, 그 저장이 `SettingsChanged`를 다시 발생시켜 `UpdateTabUI -> SwitchToTabConversation -> SaveLastConversations` 루프를 만들던 흐름이었습니다. +- 테스트: `src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs`에 동일 세션 상태 반복 저장 시 Save/이벤트가 발생하지 않는 케이스와, 실제 변경이 있을 때만 저장되는 케이스를 추가했습니다. + - 업데이트: 2026-04-15 19:46 (KST) - Code 탭 auto skill 선택을 실제 키워드·경로 매치 기반으로 다시 조정했습니다. `src/AxCopilot/Services/Agent/SkillService.cs`가 기본 점수만으로 무관한 번들 스킬을 매 요청마다 붙이지 않도록 바뀌었습니다. - 같은 파일에서 proactive auto skill은 guidance만 주고 `allowed_tools` 같은 하드 런타임 정책은 더 이상 자동 주입하지 않습니다. 빈 작업 폴더 생성 요청이 `file_write` 없이 종료되던 회귀를 막는 목적입니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 51f5146..257cd4e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1523,3 +1523,9 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - `src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs`에 `BuildProactiveSkillSystemPromptAsync_ReturnsNull_WhenNothingMeaningfullyMatches`, `BuildProactiveSkillSystemPromptAsync_DoesNotInjectHardRuntimePolicy`를 추가했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_auto_skill_runtime_fix\\ -p:IntermediateOutputPath=obj\\verify_auto_skill_runtime_fix\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SkillServiceRuntimePolicyTests|FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite" -p:OutputPath=bin\\verify_auto_skill_runtime_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_auto_skill_runtime_fix_tests\\` 통과 15 +업데이트: 2026-04-15 19:59 (KST) +- AX Agent 스트리밍 중 탭 전환, 새 대화, 경과 시간 갱신이 멈춘 것처럼 보이던 회귀를 수정했습니다. 원인은 `src/AxCopilot/Services/ChatSessionStateService.cs`가 같은 `LastActiveTab`, `LastConversationIds` 상태에서도 매번 `settings.Save()`를 호출해 `SettingsChanged -> RefreshFromSavedSettings -> UpdateTabUI -> SwitchToTabConversation -> SaveLastConversations` 순환이 계속 발생하던 흐름이었습니다. +- `ChatSessionStateService.Save(...)`는 이제 세션 스냅샷이 실제로 바뀐 경우에만 저장을 수행합니다. 스트리밍 렌더 중 같은 대화를 다시 보여줘도 설정 저장과 UI 재진입이 재발화하지 않도록 막았습니다. +- `src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs`에 `Save_DoesNotPersistOrRaiseEvents_WhenSessionStateIsUnchanged`, `Save_PersistsAndRaisesEvents_WhenSessionStateChanges`를 추가해 반복 저장 루프 회귀를 고정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_streaming_tab_loop_fix\\ -p:IntermediateOutputPath=obj\\verify_streaming_tab_loop_fix\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatSessionStateServiceTests|ChatStreamingUiPolicyTests|AxAgentExecutionEngineTests" -p:OutputPath=bin\\verify_streaming_tab_loop_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_streaming_tab_loop_fix_tests\\` 통과 51 diff --git a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs index 2bfd228..1289b60 100644 --- a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs @@ -8,6 +8,32 @@ namespace AxCopilot.Tests.Services; public class ChatSessionStateServiceTests { + private sealed class TrackingSettingsService : ISettingsService + { + public AppSettings Settings { get; } = new(); + public string? MigrationSummary => null; + public event EventHandler? SettingsChanged; + public int SaveCount { get; private set; } + public int SaveAsyncCount { get; private set; } + + public void Load() + { + } + + public void Save() + { + SaveCount++; + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + public Task SaveAsync() + { + SaveAsyncCount++; + SettingsChanged?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + } + [Fact] public void AppendExecutionEvent_CreatesConversationAndTrimsToLatest400() { @@ -533,6 +559,57 @@ public class ChatSessionStateServiceTests session.GetConversationId("Chat").Should().Be("conv-chat-lower"); } + [Fact] + public void Save_DoesNotPersistOrRaiseEvents_WhenSessionStateIsUnchanged() + { + var session = new ChatSessionStateService + { + ActiveTab = "Code", + }; + session.RememberConversation("Code", "conv-code"); + session.RememberConversation("Chat", "conv-chat"); + + var settings = new TrackingSettingsService(); + settings.Settings.Llm.LastActiveTab = "Code"; + settings.Settings.Llm.LastConversationIds = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Code"] = "conv-code", + ["Chat"] = "conv-chat", + }; + + var eventCount = 0; + settings.SettingsChanged += (_, _) => eventCount++; + + session.Save(settings); + + settings.SaveCount.Should().Be(0); + eventCount.Should().Be(0); + } + + [Fact] + public void Save_PersistsAndRaisesEvents_WhenSessionStateChanges() + { + var session = new ChatSessionStateService + { + ActiveTab = "Code", + }; + session.RememberConversation("Code", "conv-code"); + + var settings = new TrackingSettingsService(); + settings.Settings.Llm.LastActiveTab = "Chat"; + settings.Settings.Llm.LastConversationIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var eventCount = 0; + settings.SettingsChanged += (_, _) => eventCount++; + + session.Save(settings); + + settings.SaveCount.Should().Be(1); + eventCount.Should().Be(1); + settings.Settings.Llm.LastActiveTab.Should().Be("Code"); + settings.Settings.Llm.LastConversationIds.Should().ContainKey("Code").WhoseValue.Should().Be("conv-code"); + } + [Fact] public void AppendMessage_FirstUserMessageUpdatesConversationTitle() { diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index 3492861..b30948e 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -70,16 +70,49 @@ public sealed class ChatSessionStateService public void Save(ISettingsService settings) { var llm = settings.Settings.Llm; - llm.LastActiveTab = NormalizeTab(ActiveTab); + var normalizedActiveTab = NormalizeTab(ActiveTab); + var snapshot = BuildConversationSnapshot(); + if (string.Equals(NormalizeTab(llm.LastActiveTab), normalizedActiveTab, StringComparison.OrdinalIgnoreCase) + && ConversationSnapshotEquals(llm.LastConversationIds, snapshot)) + { + return; + } + + llm.LastActiveTab = normalizedActiveTab; + llm.LastConversationIds = snapshot; + settings.Save(); + } + + private Dictionary BuildConversationSnapshot() + { var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in TabConversationIds) { if (!string.IsNullOrWhiteSpace(kv.Value)) snapshot[kv.Key] = kv.Value!; } - llm.LastConversationIds = snapshot; - settings.Save(); + + return snapshot; + } + + private static bool ConversationSnapshotEquals( + IReadOnlyDictionary existing, + IReadOnlyDictionary incoming) + { + if (existing.Count != incoming.Count) + return false; + + foreach (var kv in incoming) + { + if (!existing.TryGetValue(kv.Key, out var value) + || !string.Equals(value, kv.Value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; } public void RememberConversation(string tab, string? conversationId)