Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.V2Rendering.cs
lacvet 8cb08576d5 AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
2026-04-14 17:52:46 +09:00

258 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── V2 렌더 상태 ───────────────────────────────────────────────────
private string? _v2LastRenderedConversationId;
private int _v2LastRenderedMessageCount;
private int _v2LastRenderedEventCount;
private readonly List<string> _v2LastRenderedKeys = new();
// V2 라이브 프로그레스 상태
private StackPanel? _v2LiveContainer;
private readonly Dictionary<string, Border> _v2LiveToolCards = new();
private string? _v2LastLiveToolCallId;
private void RenderMessagesV2(
ChatConversation conv,
IReadOnlyList<ChatMessage> visibleMessages,
IReadOnlyList<ChatExecutionEvent> visibleEvents,
bool preserveViewport,
double previousScrollableHeight,
double previousVerticalOffset,
Stopwatch renderStopwatch,
string? caller)
{
try
{
// 대화 전환 감지 → 캐시 초기화
if (!string.Equals(_v2LastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
{
_v2LastRenderedConversationId = conv.Id;
_v2LastRenderedKeys.Clear();
_v2LastRenderedMessageCount = 0;
_v2LastRenderedEventCount = 0;
_elementCache.Clear();
}
// ★ 패널이 비어있는데 V2 캐시가 남아있는 경우 강제 리셋
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
// 빈 대화 탭을 거치면 V2가 호출되지 않아 캐시가 잔류하는 버그 방지
if (GetTranscriptElementCount() == 0 && _v2LastRenderedKeys.Count > 0)
{
LogService.Info($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild");
_v2LastRenderedKeys.Clear();
_v2LastRenderedMessageCount = 0;
_v2LastRenderedEventCount = 0;
}
// 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합)
var timeline = BuildV2Timeline(visibleMessages, visibleEvents);
LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}");
// 새 키 목록 생성
var newKeys = new List<string>(timeline.Count);
foreach (var item in timeline)
newKeys.Add(item.Key);
// 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더
// ★ 패널에 실제 엘리먼트가 있어야 인크리멘탈 가능 —
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
// V2 캐시(_v2LastRenderedKeys)는 유지되어 "이미 렌더됨"으로 오판하는 버그 방지
var actualElementCount = GetTranscriptElementCount();
var canIncremental = _v2LastRenderedKeys.Count > 0
&& actualElementCount > 0
&& newKeys.Count >= _v2LastRenderedKeys.Count
&& KeysArePrefixMatch(_v2LastRenderedKeys, newKeys);
LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}");
if (canIncremental)
{
// 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가)
if (_v2LiveContainer != null && ContainsTranscriptElement(_v2LiveContainer))
RemoveTranscriptElement(_v2LiveContainer);
for (int i = _v2LastRenderedKeys.Count; i < timeline.Count; i++)
{
var item = timeline[i];
AddDeferredTranscriptElement(item.Key, () =>
{
if (_elementCache.TryGetValue(item.Key, out var cached))
return cached;
var element = item.CreateElement();
_elementCache[item.Key] = element;
return element;
});
}
// 라이브 컨테이너 재삽입
if (_v2LiveContainer != null && _isStreaming)
AddTranscriptElement(_v2LiveContainer);
}
else
{
// 전체 재빌드
ClearTranscriptElements();
foreach (var item in timeline)
{
var capturedItem = item;
AddDeferredTranscriptElement(capturedItem.Key, () =>
{
if (_elementCache.TryGetValue(capturedItem.Key, out var cached))
return cached;
var element = capturedItem.CreateElement();
_elementCache[capturedItem.Key] = element;
return element;
});
}
// 라이브 컨테이너 재삽입
if (_v2LiveContainer != null && _isStreaming)
AddTranscriptElement(_v2LiveContainer);
}
_v2LastRenderedKeys.Clear();
_v2LastRenderedKeys.AddRange(newKeys);
_v2LastRenderedMessageCount = visibleMessages.Count;
_v2LastRenderedEventCount = visibleEvents.Count;
LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}");
}
catch (Exception ex)
{
LogService.Error($"[V2Render] 렌더링 예외: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
}
_lastRenderTicks = Environment.TickCount64;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = conv?.ShowExecutionHistory ?? true;
renderStopwatch.Stop();
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
{
AgentPerformanceLogService.LogMetric(
"transcript", "render_messages_v2", conv?.Id ?? "", _activeTab ?? "",
renderStopwatch.ElapsedMilliseconds,
new { preserveViewport, streaming = _isStreaming, visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count });
}
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);
}
private static bool KeysArePrefixMatch(List<string> oldKeys, List<string> newKeys)
{
for (int i = 0; i < oldKeys.Count; i++)
{
if (!string.Equals(oldKeys[i], newKeys[i], StringComparison.Ordinal))
return false;
}
return true;
}
private sealed record V2TimelineItem(string Key, DateTime Timestamp, Func<UIElement> CreateElement);
private List<V2TimelineItem> BuildV2Timeline(
IReadOnlyList<ChatMessage> visibleMessages,
IReadOnlyList<ChatExecutionEvent> visibleEvents)
{
var timeline = new List<V2TimelineItem>(visibleMessages.Count + visibleEvents.Count);
// 1. 메시지 추가
foreach (var msg in visibleMessages)
{
var capturedMsg = msg;
var key = $"v2m_{msg.MsgId}";
timeline.Add(new V2TimelineItem(key, msg.Timestamp, () => CreateV2MessageElement(capturedMsg)));
}
// 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합
// 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외
var eventIndex = 0;
var events = visibleEvents.ToList();
var liveCardCutoff = (_isStreaming && _v2LiveContainer != null)
? _v2LiveStartTime
: DateTime.MaxValue;
for (int i = 0; i < events.Count; i++)
{
var executionEvent = events[i];
// 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵
if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff)
continue;
var agentEvent = ToAgentEvent(executionEvent);
// SessionStart / UserPromptSubmit 숨김
if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit)
continue;
var key = $"v2e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
// ToolCall → 바로 다음 같은 도구의 ToolResult를 찾아 병합
if (agentEvent.Type == AgentEventType.ToolCall && i + 1 < events.Count)
{
var nextEvent = ToAgentEvent(events[i + 1]);
if (nextEvent.Type == AgentEventType.ToolResult
&& string.Equals(nextEvent.ToolName, agentEvent.ToolName, StringComparison.OrdinalIgnoreCase))
{
var capturedCall = agentEvent;
var capturedResult = nextEvent;
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
() => CreateV2ToolExecutionCard(capturedCall, capturedResult)));
i++; // ToolResult 스킵
continue;
}
}
var capturedEvent = agentEvent;
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
() => CreateV2AgentEventElement(capturedEvent)));
}
// 시간순 정렬
var needsSort = false;
for (int i = 1; i < timeline.Count; i++)
{
if (timeline[i].Timestamp < timeline[i - 1].Timestamp)
{
needsSort = true;
break;
}
}
if (needsSort)
timeline.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
return timeline;
}
}