AX Agent 상태 메시지 내러티브 고도화 및 코워크/코드 진행 이력 개선

- AgentStatusNarrativeCatalog를 추가해 agent event를 탭(Cowork/Code), 도구 카테고리, 대상 힌트 기준으로 해석하고 상태 메시지/상세 설명/phase label/meta를 한 곳에서 생성하도록 정리함
- ChatWindow의 live pulse 상태, idle 진행 힌트, readable process feed 요약이 동일 narrative 카탈로그를 재사용하도록 변경해 단조로운 도구명 중심 문구를 작업 의도 중심 문구로 치환함
- README, DEVELOPMENT, NEXT_ROADMAP에 2026-04-15 12:14 (KST) 기준 이력과 남은 UX 마감 메모를 반영함

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_status_narrative\\ -p:IntermediateOutputPath=obj\\verify_status_narrative\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentStatusNarrativeCatalogTests|AgentLoopIterationPreparationServiceTests|AgentToolResultBudgetTests|ChatStorageServiceTests|AgentMessageInvariantHelperTests" -p:OutputPath=bin\\verify_status_narrative_tests\\ -p:IntermediateOutputPath=obj\\verify_status_narrative_tests\\ : 통과 15
This commit is contained in:
2026-04-15 12:15:58 +09:00
parent 717d0f2143
commit 5e40204e80
8 changed files with 681 additions and 103 deletions

View File

@@ -661,36 +661,7 @@ public partial class ChatWindow
}
private static string BuildReadableProcessFeedSummary(AgentEvent evt, string transcriptBadgeLabel, string itemDisplayName)
{
var phaseLabel = ResolveProgressPhaseLabel(evt);
if (!string.IsNullOrWhiteSpace(phaseLabel))
return phaseLabel;
return evt.Type switch
{
AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
=> "처리 중...",
AgentEventType.Thinking when string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)
=> "컨텍스트 압축 중...",
AgentEventType.Planning when evt.Steps is { Count: > 0 }
=> $"계획 {evt.Steps.Count}단계 정리",
AgentEventType.StepStart when evt.StepTotal > 0
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행",
AgentEventType.StepDone when evt.StepTotal > 0
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 완료",
AgentEventType.Thinking when !string.IsNullOrWhiteSpace(evt.Summary)
=> evt.Summary,
AgentEventType.ToolCall
=> string.IsNullOrWhiteSpace(itemDisplayName)
? $"{transcriptBadgeLabel} 실행"
: $"{itemDisplayName} 실행",
AgentEventType.SkillCall
=> string.IsNullOrWhiteSpace(itemDisplayName)
? "스킬 실행"
: $"{itemDisplayName} 실행",
_ => string.IsNullOrWhiteSpace(evt.Summary) ? transcriptBadgeLabel : evt.Summary,
};
}
=> AgentStatusNarrativeCatalog.BuildProgressStepLabel(evt, transcriptBadgeLabel, itemDisplayName);
private Border CreateReadableProcessFeedCard(
string summary,
@@ -793,59 +764,10 @@ public partial class ChatWindow
}
private static string? ResolveProgressPhaseLabel(AgentEvent evt)
{
var summary = (evt.Summary ?? string.Empty).Trim();
var toolName = (evt.ToolName ?? string.Empty).Trim();
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return "컨텍스트 압축 중...";
if (string.Equals(toolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
return "처리 중...";
if (summary.Contains("html_create", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("document_assemble", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("docx_create", StringComparison.OrdinalIgnoreCase))
return "문서 결과 생성 중...";
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("verification", StringComparison.OrdinalIgnoreCase))
return "결과 검증 중...";
if (summary.Contains("diff", StringComparison.OrdinalIgnoreCase))
return "변경 내용 확인 중...";
if (evt.Type == AgentEventType.ToolCall && !string.IsNullOrWhiteSpace(toolName))
{
return toolName switch
{
"file_read" or "directory_list" or "glob" or "grep" or "folder_map" or "multi_read" => "파일 탐색 중...",
"file_edit" or "file_write" or "html_create" or "docx_create" or "markdown_create" => "산출물 생성 중...",
"build_run" or "test_loop" => "실행 결과 확인 중...",
_ => null,
};
}
return null;
}
=> AgentStatusNarrativeCatalog.BuildProgressPhaseLabel(evt);
private static string? ResolveProgressPhaseMeta(AgentEvent evt)
{
var summary = evt.Summary ?? string.Empty;
var toolName = evt.ToolName ?? string.Empty;
if (evt.Type == AgentEventType.Planning)
return "계획";
if (evt.Type == AgentEventType.StepStart || evt.Type == AgentEventType.StepDone)
return "단계";
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return "압축";
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase) || summary.Contains("verification", StringComparison.OrdinalIgnoreCase))
return "검증";
if (summary.Contains("fallback", StringComparison.OrdinalIgnoreCase) || summary.Contains("자동 생성", StringComparison.OrdinalIgnoreCase))
return "폴백";
if (summary.Contains("재시도", StringComparison.OrdinalIgnoreCase) || summary.Contains("retry", StringComparison.OrdinalIgnoreCase))
return "재시도";
if (evt.Type == AgentEventType.ToolCall)
return "도구";
return null;
}
=> AgentStatusNarrativeCatalog.BuildProgressPhaseMeta(evt);
private Border CreateReadableProgressFeedCard(
string summary,

View File

@@ -24,14 +24,19 @@ public partial class ChatWindow
private const int MaxStatusSubItems = 6;
// ShowStreamingStatusBar → 펄스 닷 바로 위임 (플로팅 상태 바 표시 안 함)
private void ShowStreamingStatusBar(string message, string? iconCode = null)
=> ShowPulseDots(message, iconCode);
private void ShowStreamingStatusBar(string message, string? iconCode = null, string? detail = null)
=> ShowPulseDots(message, iconCode, detail);
private void HideStreamingStatusBar()
=> HidePulseDots();
private void UpdateStreamingStatusBar(string message, string? iconCode = null)
=> UpdatePulseDotsText(message, iconCode);
private void UpdateStreamingStatusBar(
string message,
string? iconCode = null,
string? detail = null,
bool clearSubItems = false,
string? subItemCategory = null)
=> UpdatePulseDotsText(message, iconCode, detail, clearSubItems, subItemCategory);
// ─── 입력창 위 펄스 닷 애니메이션 ──────────────────────────────────────────
@@ -41,6 +46,8 @@ public partial class ChatWindow
if (PulseDotStatusText != null)
PulseDotStatusText.Text = message ?? "생각하는 중...";
ClearStatusSubItems();
if (!string.IsNullOrWhiteSpace(detail))
AddStatusSubItem(detail);
PulseDotBar.Visibility = Visibility.Visible;
StartStatusDiamondAnimation();
if (_pulseDotStoryboard != null) return; // 이미 실행 중
@@ -325,8 +332,18 @@ public partial class ChatWindow
var idle = DateTime.UtcNow - _lastAgentProgressEventAt;
TryGetStreamingElapsed(out var elapsed);
string? summary = null;
var toolName = "agent_wait";
var lastProgressEvent = _currentRunProgressSteps.Count > 0
? _currentRunProgressSteps[^1]
: null;
var idleNarrative = AgentStatusNarrativeCatalog.BuildIdle(
lastProgressEvent,
runTab,
idle,
elapsed,
_pendingPostCompaction);
string? summary = idleNarrative.Message;
var toolName = _pendingPostCompaction ? "context_compaction" : "agent_wait";
UpdateStreamingStatusBar(idleNarrative.Message, detail: idleNarrative.Detail);
if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2))
{
@@ -359,6 +376,17 @@ public partial class ChatWindow
summary = "작업을 진행하는 중입니다...";
}
summary = idleNarrative.Message;
if (_pendingPostCompaction || idle >= TimeSpan.FromSeconds(30))
{
UpdateStreamingStatusBar(
summary,
_pendingPostCompaction ? "\uE72C" : "\uE895",
idleNarrative.Detail,
clearSubItems: true,
subItemCategory: idleNarrative.Category);
}
UpdateLiveAgentProgressHint(summary, toolName);
}

View File

@@ -6632,21 +6632,18 @@ public partial class ChatWindow : Window
// ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ───────────
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
{
switch (evt.Type)
if (evt.Type is AgentEventType.Complete or AgentEventType.Error)
{
case AgentEventType.ToolCall when !string.IsNullOrWhiteSpace(evt.ToolName):
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = GetStatusInfoForTool(evt.ToolName).message + "...";
break;
case AgentEventType.ToolResult when !string.IsNullOrWhiteSpace(evt.ToolName):
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = GetToolResultMessage(evt.ToolName) + "...";
break;
case AgentEventType.Complete:
case AgentEventType.Error:
HideStreamingStatusBar();
FlushPendingAgentUiEvent();
break;
HideStreamingStatusBar();
FlushPendingAgentUiEvent();
}
else if (PulseDotBar?.Visibility == Visibility.Visible)
{
var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab);
UpdateStreamingStatusBar(
liveStatus.Message,
detail: liveStatus.Detail,
subItemCategory: liveStatus.Category);
}
}
@@ -6706,15 +6703,17 @@ public partial class ChatWindow : Window
if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|| string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
{
UpdateLiveAgentProgressHint("작업을 준비하는 중입니다...", "agent_wait");
var initialStatus = AgentStatusNarrativeCatalog.BuildInitial(runTab);
UpdateLiveAgentProgressHint(initialStatus.Message, "agent_wait");
ShowStreamingStatusBar(initialStatus.Message, detail: initialStatus.Detail);
}
else
{
UpdateLiveAgentProgressHint(null);
ShowStreamingStatusBar("생각하는 중...");
}
_agentProgressHintTimer.Stop();
_agentProgressHintTimer.Start();
ShowStreamingStatusBar("생각하는 중...");
}
private void StopLiveAgentProgressHints()