AX Agent 구조를 claw-code 기준으로 추가 정리해 transcript 렌더와 tool streaming 책임을 분리함
Some checks failed
Release Gate / gate (push) Has been cancelled

- ChatWindow.TranscriptRendering partial을 추가해 transcript windowing, 증분 렌더, 스크롤 보존 로직을 메인 ChatWindow.xaml.cs에서 분리
- StreamingToolExecutionCoordinator를 도입해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 계층으로 이동
- AgentLoopRuntimeThresholds helper를 추가해 no-tool, plan retry, terminal evidence gate 임계값 계산을 AgentLoopService에서 분리
- AgentLoopTransitions.Execution은 coordinator thin wrapper 중심 구조로 정리해 이후 executor 고도화와 정책 변경이 덜 위험하도록 개선
- README와 docs/DEVELOPMENT.md를 2026-04-09 09:14 (KST) 기준으로 갱신
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
2026-04-09 00:16:54 +09:00
parent 74d43e701c
commit 8643562319
8 changed files with 441 additions and 343 deletions

View File

@@ -2284,181 +2284,6 @@ public partial class ChatWindow : Window
private string? _lastRenderedConversationId;
private int _timelineRenderLimit = TimelineRenderPageSize;
private int GetActiveTimelineRenderLimit()
{
if (!_isStreaming)
return _timelineRenderLimit;
var streamingLimit = IsLightweightLiveProgressMode()
? TimelineLightweightStreamingRenderLimit
: TimelineStreamingRenderLimit;
return Math.Min(_timelineRenderLimit, streamingLimit);
}
private void RenderMessages(bool preserveViewport = false)
{
var previousScrollableHeight = GetTranscriptScrollableHeight();
var previousVerticalOffset = GetTranscriptVerticalOffset();
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
var visibleMessages = GetVisibleTimelineMessages(conv);
var visibleEvents = GetVisibleTimelineEvents(conv);
// 스트리밍 중 메시지·이벤트 수가 동일하면 불필요한 재렌더링 스킵
if (_isStreaming && preserveViewport
&& visibleMessages.Count == _lastRenderedMessageCount
&& visibleEvents.Count == _lastRenderedEventCount
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
return;
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
{
ClearTranscriptElements();
_runBannerAnchors.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
EmptyState.Visibility = Visibility.Visible;
return;
}
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
{
_lastRenderedConversationId = conv.Id;
_timelineRenderLimit = TimelineRenderPageSize;
_elementCache.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
InvalidateTimelineCache();
}
var showHistory = conv.ShowExecutionHistory;
EmptyState.Visibility = Visibility.Collapsed;
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
var visibleTimeline = hiddenCount > 0
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
: orderedTimeline;
var newKeys = new List<string>(visibleTimeline.Count);
foreach (var t in visibleTimeline) newKeys.Add(t.Key);
var incremented = false;
// ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ──
// agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드
var hasExternalChildren = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
var canIncremental = !hasExternalChildren
&& _lastRenderedTimelineKeys.Count > 0
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
&& _lastRenderedHiddenCount == hiddenCount
&& GetTranscriptElementCount() == expectedChildCount;
if (canIncremental)
{
// _live_ 키 개수를 한 번만 계산 (이전 키 목록에서)
var prevLiveCount = 0;
for (int i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
{
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
prevLiveCount++;
else
break; // live 키는 항상 끝에 연속으로 위치
}
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
// 안정 키(non-live) 접두사가 일치하는지 확인
var prefixMatch = true;
for (int i = 0; i < prevStableCount; i++)
{
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
{
prefixMatch = false;
break;
}
}
if (prefixMatch)
{
try
{
// 이전 live 요소를 Children 끝에서 제거
for (int r = 0; r < prevLiveCount && GetTranscriptElementCount() > 0; r++)
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
// 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함)
for (int i = prevStableCount; i < visibleTimeline.Count; i++)
visibleTimeline[i].Render();
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
incremented = true;
}
catch (Exception ex)
{
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
_lastRenderedTimelineKeys.Clear();
incremented = false;
}
}
}
if (!incremented)
{
// ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ──
ClearTranscriptElements();
_runBannerAnchors.Clear();
if (hiddenCount > 0)
AddTranscriptElement(CreateTimelineLoadMoreCard(hiddenCount));
foreach (var item in visibleTimeline)
item.Render();
// 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원
if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer))
AddTranscriptElement(_agentLiveContainer);
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
}
// ── 스크롤 처리 ──
if (!preserveViewport)
{
_ = Dispatcher.InvokeAsync(() =>
{
ScrollTranscriptToEnd();
}, DispatcherPriority.Background);
return;
}
_ = Dispatcher.InvokeAsync(() =>
{
if (_transcriptScrollViewer == null)
return;
var newScrollableHeight = GetTranscriptScrollableHeight();
var delta = newScrollableHeight - previousScrollableHeight;
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
ScrollTranscriptToVerticalOffset(targetOffset);
}, DispatcherPriority.Background);
}
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>