AX Agent 스트리밍 중 탭 전환과 새 대화가 멈추는 저장 루프 회귀 수정

원인: 같은 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)
This commit is contained in:
2026-04-15 20:00:54 +09:00
parent f3717cda21
commit 22261579d0
4 changed files with 124 additions and 3 deletions

View File

@@ -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<string, string>(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<string, string>(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()
{

View File

@@ -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<string, string> BuildConversationSnapshot()
{
var snapshot = new Dictionary<string, string>(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<string, string> existing,
IReadOnlyDictionary<string, string> 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)