AX Agent 상단 라이브 안내 카드 유지 회귀를 수정한다

- 같은 탭에 실행이 살아 있는 동안에는 현재 대화와 실행 대화가 잠깐 어긋나도 상단 라이브 카드와 상태 바를 숨기지 않도록 ChatWindow 스트리밍 분기를 조정함

- ChatStreamingUiPolicy를 추가해 Hidden/ActiveConversation/BackgroundConversation 상태를 분리하고 본문 실행 이력 렌더와 상단 진행 안내 표시를 분리함

- ChatStreamingUiPolicyTests를 추가하고 README.md, docs/DEVELOPMENT.md 이력을 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_guide_persistence\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence\\ (경고 0, 오류 0)

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatStreamingUiPolicyTests|ChatWindowSlashPolicyTests|ChatSessionStateServiceTests|AxAgentExecutionEngineTests -p:OutputPath=bin\\verify_live_guide_persistence_tests\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence_tests\\ (통과 98)
This commit is contained in:
2026-04-15 19:35:24 +09:00
parent f173e2a63b
commit 8721a0d8c7
5 changed files with 120 additions and 14 deletions

View File

@@ -0,0 +1,54 @@
using AxCopilot.Views;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Views;
public class ChatStreamingUiPolicyTests
{
[Theory]
[InlineData(false, false, nameof(StreamingGuideVisibility.Hidden))]
[InlineData(false, true, nameof(StreamingGuideVisibility.Hidden))]
[InlineData(true, true, nameof(StreamingGuideVisibility.ActiveConversation))]
[InlineData(true, false, nameof(StreamingGuideVisibility.BackgroundConversation))]
public void ResolveGuideVisibility_ShouldClassifyStreamingState(
bool isOwningActiveTab,
bool isVisibleStreamingConversation,
string expectedName)
{
var result = ChatStreamingUiPolicy.ResolveGuideVisibility(
isOwningActiveTab,
isVisibleStreamingConversation);
var expected = Enum.Parse<StreamingGuideVisibility>(expectedName);
result.Should().Be(expected);
}
[Theory]
[InlineData(nameof(StreamingGuideVisibility.Hidden), false)]
[InlineData(nameof(StreamingGuideVisibility.ActiveConversation), true)]
[InlineData(nameof(StreamingGuideVisibility.BackgroundConversation), true)]
public void ShouldShowTopLevelGuide_ShouldKeepGuideVisibleForBackgroundConversation(
string visibilityName,
bool expected)
{
var visibility = Enum.Parse<StreamingGuideVisibility>(visibilityName);
var result = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(visibility);
result.Should().Be(expected);
}
[Theory]
[InlineData(nameof(StreamingGuideVisibility.Hidden), false)]
[InlineData(nameof(StreamingGuideVisibility.ActiveConversation), true)]
[InlineData(nameof(StreamingGuideVisibility.BackgroundConversation), false)]
public void ShouldRenderConversationBoundContent_ShouldOnlyRenderVisibleConversationTimeline(
string visibilityName,
bool expected)
{
var visibility = Enum.Parse<StreamingGuideVisibility>(visibilityName);
var result = ChatStreamingUiPolicy.ShouldRenderConversationBoundContent(visibility);
result.Should().Be(expected);
}
}

View File

@@ -0,0 +1,29 @@
namespace AxCopilot.Views;
internal enum StreamingGuideVisibility
{
Hidden,
ActiveConversation,
BackgroundConversation,
}
internal static class ChatStreamingUiPolicy
{
internal static StreamingGuideVisibility ResolveGuideVisibility(
bool isOwningActiveTab,
bool isVisibleStreamingConversation)
{
if (!isOwningActiveTab)
return StreamingGuideVisibility.Hidden;
return isVisibleStreamingConversation
? StreamingGuideVisibility.ActiveConversation
: StreamingGuideVisibility.BackgroundConversation;
}
internal static bool ShouldShowTopLevelGuide(StreamingGuideVisibility visibility)
=> visibility != StreamingGuideVisibility.Hidden;
internal static bool ShouldRenderConversationBoundContent(StreamingGuideVisibility visibility)
=> visibility == StreamingGuideVisibility.ActiveConversation;
}

View File

@@ -1536,7 +1536,10 @@ public partial class ChatWindow : Window
private void RefreshStreamingControlsForActiveTab()
{
var isOwningTab = _streamingTabs.Contains(_activeTab);
var hasVisibleStreamingConversation = isOwningTab && IsStreamingConversationVisible(_activeTab);
var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility(
isOwningTab,
isOwningTab && IsStreamingConversationVisible(_activeTab));
var shouldShowTopLevelGuide = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(guideVisibility);
// 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨
BtnSend.IsEnabled = true;
@@ -1551,19 +1554,19 @@ public partial class ChatWindow : Window
// 스트리밍 탭으로 복귀 시 자동 복원
if (PulseDotBar != null)
{
if (!hasVisibleStreamingConversation)
if (!shouldShowTopLevelGuide)
PulseDotBar.Visibility = Visibility.Collapsed;
else if (_streamingTabs.Count > 0)
PulseDotBar.Visibility = Visibility.Visible;
}
if (isOwningTab && !hasVisibleStreamingConversation)
if (!shouldShowTopLevelGuide)
{
RemoveAgentLiveCard(animated: false);
HideStreamingStatusBar();
HideStickyProgress();
}
else if (hasVisibleStreamingConversation && IsAgentLiveCardEligibleTab(_activeTab))
else if (IsAgentLiveCardEligibleTab(_activeTab))
{
EnsureAgentLiveCardVisible(_activeTab);
if (_pendingAgentUiEvent != null)
@@ -6799,24 +6802,32 @@ public partial class ChatWindow : Window
{
TouchLiveAgentProgressHints();
var eventTab = runTab;
var isVisibleRunConversation = IsStreamingConversationVisible(runTab);
var normalizedRunTab = NormalizeTabName(runTab);
var isOwningActiveTab = string.Equals(normalizedRunTab, _activeTab, StringComparison.OrdinalIgnoreCase)
&& _streamingTabs.Contains(normalizedRunTab);
var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility(
isOwningActiveTab,
isOwningActiveTab && IsStreamingConversationVisible(runTab));
var shouldShowTopLevelGuide = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(guideVisibility);
var shouldRenderConversationBoundContent =
ChatStreamingUiPolicy.ShouldRenderConversationBoundContent(guideVisibility);
// V2 라이브 카드 실시간 업데이트
if (isVisibleRunConversation && IsAgentLiveCardEligibleTab(runTab))
if (shouldShowTopLevelGuide && IsAgentLiveCardEligibleTab(runTab))
{
EnsureAgentLiveCardVisible(runTab);
UpdateAgentLiveCardV2(evt);
}
// ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ───────────
if (isVisibleRunConversation)
if (shouldShowTopLevelGuide)
{
if (evt.Type is AgentEventType.Complete or AgentEventType.Error)
{
HideStreamingStatusBar();
FlushPendingAgentUiEvent();
}
else if (PulseDotBar?.Visibility == Visibility.Visible)
else if (PulseDotBar != null)
{
var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab);
UpdateStreamingStatusBar(
@@ -6827,7 +6838,7 @@ public partial class ChatWindow : Window
}
// 현재 실행 중인 스텝 추적 (통합 진행 카드용)
if (IsProcessFeedEvent(evt) && isVisibleRunConversation)
if (IsProcessFeedEvent(evt) && shouldShowTopLevelGuide)
{
_currentRunProgressSteps.Add(evt);
if (_currentRunProgressSteps.Count > 8)
@@ -6837,11 +6848,11 @@ public partial class ChatWindow : Window
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
var shouldShowExecutionHistory = isVisibleRunConversation && (_currentConversation?.ShowExecutionHistory ?? false);
var lightweightLiveMode = isVisibleRunConversation
var shouldShowExecutionHistory = shouldRenderConversationBoundContent && (_currentConversation?.ShowExecutionHistory ?? false);
var lightweightLiveMode = shouldRenderConversationBoundContent
&& !shouldShowExecutionHistory
&& IsLightweightLiveProgressMode(eventTab);
var shouldRender = isVisibleRunConversation && (
var shouldRender = shouldRenderConversationBoundContent && (
shouldShowExecutionHistory
|| (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|| evt.Type == AgentEventType.Complete
@@ -6857,11 +6868,11 @@ public partial class ChatWindow : Window
{
_tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens;
_tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens;
if (isVisibleRunConversation)
if (shouldShowTopLevelGuide)
UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]);
}
if (isVisibleRunConversation)
if (shouldShowTopLevelGuide)
ScheduleAgentUiEvent(evt);
if (!lightweightLiveMode
|| evt.Type is AgentEventType.Complete