using System; using System.Collections.Generic; using System.IO; using System.Text.Json; namespace AxCopilot.Services.Agent; internal sealed record AgentStatusNarrative( string Message, string? Detail, string Category, string? Meta = null); internal static class AgentStatusNarrativeCatalog { public static AgentStatusNarrative BuildInitial(string? runTab) => IsCodeTab(runTab) ? new AgentStatusNarrative( "작업 범위와 관련 파일을 먼저 파악하고 있습니다...", "필요한 코드, 로그, 테스트 범위를 정리한 뒤 바로 수정 단계로 이어갑니다.", "initialize") : new AgentStatusNarrative( "요청 목적과 필요한 자료를 정리하고 있습니다...", "관련 문서와 작업 범위를 확인한 뒤 바로 다음 단계로 이어갑니다.", "initialize"); public static AgentStatusNarrative BuildFromEvent(AgentEvent evt, string? runTab) { var itemDisplayName = evt.Type == AgentEventType.SkillCall ? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true) : AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName); var transcriptBadgeLabel = AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt); var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation(evt, itemDisplayName, transcriptBadgeLabel); var category = ResolveCategory(evt, row); var message = BuildEventMessage(evt, runTab, row, category, itemDisplayName); var detail = BuildEventDetail(evt, row, category, message); return new AgentStatusNarrative( SanitizeSingleLine(message), SanitizeDetail(detail), category, BuildProgressPhaseMeta(evt)); } public static AgentStatusNarrative BuildIdle( AgentEvent? lastProgressEvent, string? runTab, TimeSpan idle, TimeSpan elapsed, bool pendingPostCompaction) { if (pendingPostCompaction) { return new AgentStatusNarrative( "긴 대화를 이어가기 위해 컨텍스트를 정리하고 있습니다...", "최근 작업 결과를 압축해 다음 단계에 필요한 정보만 남기고 있습니다.", "compact"); } var category = lastProgressEvent == null ? (IsCodeTab(runTab) ? "read" : "plan") : ResolveCategory( lastProgressEvent, AgentTranscriptDisplayCatalog.ResolveRowPresentation( lastProgressEvent, lastProgressEvent.Type == AgentEventType.SkillCall ? AgentTranscriptDisplayCatalog.GetDisplayName(lastProgressEvent.ToolName, slashPrefix: true) : AgentTranscriptDisplayCatalog.GetDisplayName(lastProgressEvent.ToolName), AgentTranscriptDisplayCatalog.GetEventBadgeLabel(lastProgressEvent))); if (idle >= TimeSpan.FromSeconds(90)) { return new AgentStatusNarrative( "현재까지 확인한 내용과 다음 단계를 차분히 정리하고 있습니다...", BuildIdleDetail(category), category); } if (idle >= TimeSpan.FromSeconds(30)) { return new AgentStatusNarrative( BuildIdleMessage(category, runTab, longWait: true), BuildIdleDetail(category), category); } if (idle >= TimeSpan.FromSeconds(12)) { return new AgentStatusNarrative( BuildIdleMessage(category, runTab, longWait: false), BuildIdleDetail(category), category); } if (idle >= TimeSpan.FromSeconds(5)) { return new AgentStatusNarrative( "다음 단계를 정리하고 있습니다...", BuildIdleDetail(category), category); } if (elapsed >= TimeSpan.FromSeconds(4)) { return new AgentStatusNarrative( "작업을 이어가고 있습니다...", BuildIdleDetail(category), category); } return BuildInitial(runTab); } public static string BuildProgressStepLabel(AgentEvent evt, string transcriptBadgeLabel, string itemDisplayName) { var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation(evt, itemDisplayName, transcriptBadgeLabel); var category = ResolveCategory(evt, row); if (evt.Type == AgentEventType.ToolCall) { return category switch { "read" => "관련 코드와 파일을 확인하는 중", "edit" => "변경을 적용하는 중", "execute" => "실행 결과를 확인하는 중", "document" => "문서 내용을 구성하는 중", "git" => "변경 범위를 확인하는 중", "web" => "필요한 정보를 정리하는 중", _ => row.Title, }; } if (evt.Type == AgentEventType.ToolResult) { return category switch { "read" => "확인한 내용을 정리하는 중", "edit" => "적용한 변경을 정리하는 중", "execute" => "실행 결과를 분석하는 중", "document" => "생성 결과를 검토하는 중", _ => row.Title, }; } return row.Title; } public static string? BuildProgressPhaseLabel(AgentEvent evt) { var category = ResolveCategory( evt, AgentTranscriptDisplayCatalog.ResolveRowPresentation( evt, evt.Type == AgentEventType.SkillCall ? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true) : AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName), AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt))); if (category == "compact") return "컨텍스트를 정리하는 중..."; if (evt.Type == AgentEventType.Thinking && string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)) return "다음 응답을 준비하는 중..."; return evt.Type switch { AgentEventType.Planning => "작업 순서를 정리하는 중...", AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행 중...", AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 정리 중...", AgentEventType.PermissionRequest => "권한 확인을 기다리는 중...", AgentEventType.PermissionDenied => "대체 경로를 검토하는 중...", AgentEventType.ToolCall => category switch { "read" => "관련 파일을 확인하는 중...", "edit" => "변경을 적용하는 중...", "execute" => "실행 결과를 확인하는 중...", "document" => "문서를 구성하는 중...", "git" => "변경 범위를 정리하는 중...", "web" => "필요한 정보를 찾는 중...", _ => null, }, AgentEventType.ToolResult => category switch { "read" => "읽은 내용을 정리하는 중...", "edit" => "수정 내용을 정리하는 중...", "execute" => "실행 결과를 분석하는 중...", "document" => "생성 결과를 검토하는 중...", _ => null, }, _ => null, }; } public static string? BuildProgressPhaseMeta(AgentEvent evt) { var itemDisplayName = evt.Type == AgentEventType.SkillCall ? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true) : AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName); var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation( evt, itemDisplayName, AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt)); var category = ResolveCategory(evt, row); return evt.Type switch { AgentEventType.Planning => "계획", AgentEventType.StepStart or AgentEventType.StepDone => "단계", AgentEventType.PermissionRequest => "권한", AgentEventType.PermissionGranted => "권한 확인", AgentEventType.PermissionDenied => "권한 거부", AgentEventType.ToolCall => category switch { "read" => "탐색", "edit" => "수정", "execute" => "실행", "document" => "문서", "git" => "Git", "web" => "자료", _ => "도구", }, AgentEventType.ToolResult => category switch { "read" => "읽기 완료", "edit" => "수정 완료", "execute" => "실행 결과", "document" => "생성 결과", _ => "결과", }, _ => null, }; } private static string BuildEventMessage( AgentEvent evt, string? runTab, AgentTranscriptRowPresentation row, string category, string itemDisplayName) { return evt.Type switch { AgentEventType.Planning => IsCodeTab(runTab) ? "수정 순서와 검증 단계를 정리하고 있습니다..." : "작업 순서와 결과물 구성을 정리하고 있습니다...", AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 작업을 진행하고 있습니다...", AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 이어가고 있습니다...", AgentEventType.PermissionRequest => "실행 전에 필요한 권한 확인을 기다리고 있습니다...", AgentEventType.PermissionGranted => "권한이 확인되어 작업을 이어가고 있습니다...", AgentEventType.PermissionDenied => "권한이 거부되어 다른 진행 경로를 검토하고 있습니다...", AgentEventType.SkillCall => "적절한 스킬이나 작업 흐름을 적용하고 있습니다...", AgentEventType.ToolCall => category switch { "read" => IsCodeTab(runTab) ? "관련 코드와 파일을 확인하고 있습니다..." : "관련 자료와 문서를 확인하고 있습니다...", "edit" => IsCodeTab(runTab) ? "코드 변경을 적용하고 있습니다..." : "초안 내용을 다듬고 있습니다...", "execute" => "실행 결과와 로그를 확인하고 있습니다...", "document" => "문서 결과물을 구성하고 있습니다...", "git" => "변경 범위와 저장소 상태를 확인하고 있습니다...", "web" => "필요한 외부 정보를 정리하고 있습니다...", _ => string.IsNullOrWhiteSpace(itemDisplayName) ? "필요한 작업을 진행하고 있습니다..." : $"{itemDisplayName} 작업을 진행하고 있습니다...", }, AgentEventType.ToolResult => category switch { "read" => "확인한 내용을 정리하고 다음 단계로 이어가고 있습니다...", "edit" => "적용한 변경을 정리하고 다음 검증으로 이어가고 있습니다...", "execute" => "실행 결과를 분석하고 후속 조치를 판단하고 있습니다...", "document" => "생성한 결과를 검토하고 다듬고 있습니다...", "git" => "변경 내용을 정리하고 다음 작업을 준비하고 있습니다...", "web" => "수집한 정보를 정리하고 답변에 반영하고 있습니다...", _ => string.IsNullOrWhiteSpace(row.Title) ? "결과를 정리하고 있습니다..." : EnsureSentence(row.Title), }, AgentEventType.Thinking when string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase) => "긴 대화를 이어가기 위해 컨텍스트를 압축하고 있습니다...", AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) => "현재까지 진행한 내용을 정리하고 있습니다...", AgentEventType.Thinking when category == "document" => "문서 흐름과 필요한 내용을 정리하고 있습니다...", AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("검증", StringComparison.OrdinalIgnoreCase) => "결과를 검토하고 빠진 부분이 없는지 확인하고 있습니다...", AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("재시도", StringComparison.OrdinalIgnoreCase) => "다시 시도할 경로를 정리하고 있습니다...", AgentEventType.Complete => "작업이 완료되었습니다.", AgentEventType.Error => "오류를 정리하고 복구 경로를 검토하고 있습니다...", AgentEventType.Decision => "다음 진행을 위해 사용자 확인을 기다리고 있습니다...", _ => string.IsNullOrWhiteSpace(row.Title) ? "작업을 진행하고 있습니다..." : EnsureSentence(row.Title), }; } private static string? BuildEventDetail( AgentEvent evt, AgentTranscriptRowPresentation row, string category, string message) { var parts = new List(); var baseDescription = SanitizeDetail(row.Description); if (!string.IsNullOrWhiteSpace(baseDescription) && !string.Equals(baseDescription, message, StringComparison.OrdinalIgnoreCase)) { parts.Add(baseDescription); } if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 }) parts.Add($"총 {evt.Steps.Count}단계 계획을 기준으로 진행합니다."); var targetHint = ExtractTargetHint(evt); if (!string.IsNullOrWhiteSpace(targetHint)) parts.Add($"대상: {targetHint}"); if (category == "compact" && evt.ElapsedMs > 0) parts.Add("이전 대화와 최근 작업 결과를 이어서 사용할 수 있게 정리하고 있습니다."); return parts.Count == 0 ? null : string.Join(" · ", parts); } private static string BuildIdleMessage(string category, string? runTab, bool longWait) => category switch { "read" => IsCodeTab(runTab) ? longWait ? "읽은 코드와 검색 결과를 종합해 다음 수정 지점을 정리하고 있습니다..." : "읽은 코드와 검색 결과를 정리하고 있습니다..." : longWait ? "확인한 자료를 종합해 답변 구조를 정리하고 있습니다..." : "확인한 자료와 문서를 정리하고 있습니다...", "edit" => longWait ? "적용한 변경 내용을 다시 검토하고 검증 포인트를 정리하고 있습니다..." : "적용한 변경 내용을 다시 점검하고 있습니다...", "execute" => longWait ? "실행 로그를 종합해 원인과 다음 조치를 정리하고 있습니다..." : "실행 결과와 로그를 정리하고 있습니다...", "document" => longWait ? "초안 구조와 빠진 근거를 다시 맞추고 있습니다..." : "초안과 결과물 구성을 다듬고 있습니다...", "git" => "변경 범위와 저장소 상태를 다시 정리하고 있습니다...", "web" => "수집한 정보를 정리해 답변에 연결하고 있습니다...", "permission" => "권한 확인 결과를 기다리며 다음 단계를 준비하고 있습니다...", _ => longWait ? "현재까지의 진행 내용을 종합하고 다음 단계를 정리하고 있습니다..." : "다음 단계를 정리하고 있습니다...", }; private static string BuildIdleDetail(string category) => category switch { "read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결하고 있습니다.", "edit" => "수정 범위와 영향 파일을 다시 확인하고 있습니다.", "execute" => "빌드와 테스트 로그에서 원인과 후속 조치를 추리고 있습니다.", "document" => "초안 구조, 빠진 근거, 연결 흐름을 다시 맞추고 있습니다.", "git" => "변경 범위와 저장소 상태를 한 번 더 확인하고 있습니다.", "web" => "수집한 정보를 질문 흐름에 맞게 추리고 있습니다.", "permission" => "권한 결과가 정리되면 같은 작업 흐름으로 바로 이어집니다.", _ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결하고 있습니다.", }; private static string ResolveCategory(AgentEvent evt, AgentTranscriptRowPresentation row) { var groupKey = row.GroupKey ?? string.Empty; if (groupKey.StartsWith("activity:", StringComparison.OrdinalIgnoreCase)) return groupKey["activity:".Length..]; if (groupKey.StartsWith("permission:", StringComparison.OrdinalIgnoreCase)) return "permission"; if (groupKey.StartsWith("compact:", StringComparison.OrdinalIgnoreCase)) return "compact"; if (groupKey.StartsWith("waiting:", StringComparison.OrdinalIgnoreCase)) return "wait"; if (groupKey.StartsWith("planning", StringComparison.OrdinalIgnoreCase)) return "plan"; if (groupKey.StartsWith("thinking:", StringComparison.OrdinalIgnoreCase)) return groupKey["thinking:".Length..]; if (groupKey.StartsWith("step:", StringComparison.OrdinalIgnoreCase)) return "step"; return evt.Type switch { AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied => "permission", AgentEventType.Planning => "plan", AgentEventType.StepStart or AgentEventType.StepDone => "step", AgentEventType.Complete => "complete", AgentEventType.Error => "error", _ => "general", }; } private static string? ExtractTargetHint(AgentEvent evt) { if (!string.IsNullOrWhiteSpace(evt.FilePath)) return Path.GetFileName(evt.FilePath); if (string.IsNullOrWhiteSpace(evt.ToolInput)) return null; try { using var doc = JsonDocument.Parse(evt.ToolInput); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var key in new[] { "path", "file_path", "file", "file_name", "title", "query", "pattern", "url", "command", "sheet_name", "dashboard_sheet_name" }) { if (!doc.RootElement.TryGetProperty(key, out var value)) continue; var normalized = NormalizeTargetValue(key, value); if (!string.IsNullOrWhiteSpace(normalized)) return normalized; } } catch { // ignore malformed tool_input } return null; } private static string? NormalizeTargetValue(string key, JsonElement value) { if (value.ValueKind == JsonValueKind.String) { var text = value.GetString()?.Trim(); if (string.IsNullOrWhiteSpace(text)) return null; if (key is "path" or "file_path" or "file" or "file_name") return Path.GetFileName(text); if (key == "command") return text.Length > 60 ? text[..60] + "..." : text; return text.Length > 70 ? text[..70] + "..." : text; } if (value.ValueKind == JsonValueKind.Array) { foreach (var item in value.EnumerateArray()) { var normalized = NormalizeTargetValue(key, item); if (!string.IsNullOrWhiteSpace(normalized)) return normalized; } } return null; } private static bool IsCodeTab(string? runTab) => string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase); private static string EnsureSentence(string text) { var normalized = SanitizeSingleLine(text); if (string.IsNullOrWhiteSpace(normalized)) return "작업을 진행하고 있습니다..."; if (normalized.EndsWith("...", StringComparison.Ordinal)) return normalized; if (normalized.EndsWith("중", StringComparison.Ordinal)) return normalized + "..."; return normalized; } private static string SanitizeSingleLine(string text) { var normalized = AgentTranscriptDisplayCatalog.StripNonBmpCharacters(text ?? string.Empty) .Replace("\r", " ") .Replace("\n", " ") .Trim(); while (normalized.Contains(" ", StringComparison.Ordinal)) normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); return normalized; } private static string? SanitizeDetail(string? text) { if (string.IsNullOrWhiteSpace(text)) return null; var normalized = AgentTranscriptDisplayCatalog.StripNonBmpCharacters(text) .Replace("\r", " ") .Replace("\n", " ") .Trim(); while (normalized.Contains(" ", StringComparison.Ordinal)) normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); if (normalized.Length > 160) normalized = normalized[..160].TrimEnd() + "..."; return normalized; } }