using AxCopilot.Services.Agent; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 앱 전역에서 공유되는 상태 요약 저장소. /// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아 /// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다. /// public sealed class AppStateService { public sealed class SkillCatalogState { public bool Enabled { get; set; } public string FolderPath { get; set; } = ""; public int LoadedCount { get; set; } public int AvailableCount { get; set; } public int UnavailableCount { get; set; } } public sealed class McpCatalogState { public int ConfiguredServerCount { get; set; } public int EnabledServerCount { get; set; } public int ConnectedServerCount { get; set; } public int RegisteredToolCount { get; set; } } public sealed class PermissionPolicyState { public string FilePermission { get; set; } = "Deny"; public string AgentDecisionLevel { get; set; } = "detailed"; public int ToolOverrideCount { get; set; } public List> ToolOverrides { get; set; } = new(); } public sealed class AgentCatalogState { public int RegisteredToolCount { get; set; } public int McpToolCount { get; set; } public int SkillToolCount { get; set; } } public sealed class PermissionEventState { public string RunId { get; init; } = ""; public string ToolName { get; init; } = ""; public string Status { get; init; } = "requested"; public string Summary { get; init; } = ""; public DateTime Timestamp { get; init; } = DateTime.Now; } public sealed class HookEventState { public string RunId { get; init; } = ""; public string ToolName { get; init; } = ""; public bool Success { get; init; } public string Summary { get; init; } = ""; public DateTime Timestamp { get; init; } = DateTime.Now; } public sealed class BackgroundJobSummaryState { public int ActiveCount { get; init; } public BackgroundJobService.BackgroundJobState? LatestRecent { get; init; } } public sealed class PermissionSummaryState { public string EffectiveMode { get; init; } = "Deny"; public string DefaultMode { get; init; } = "Deny"; public int OverrideCount { get; init; } public string RiskLevel { get; init; } = "normal"; public string Description { get; init; } = ""; public IReadOnlyList> TopOverrides { get; init; } = Array.Empty>(); } public sealed class AgentRunState { public string RunId { get; set; } = ""; public string Status { get; set; } = "idle"; public string Summary { get; set; } = ""; public int LastIteration { get; set; } public DateTime StartedAt { get; set; } = DateTime.Now; public DateTime UpdatedAt { get; set; } = DateTime.Now; } public sealed class TaskSummaryState { public int ActiveCount { get; init; } public int PendingPermissionCount { get; init; } public TaskRunStore.TaskRun? LatestRecentTask { get; init; } public AgentRunState? LatestFailedRun { get; init; } public bool HasActiveTasks => ActiveCount > 0; } public sealed class ConversationRunSummaryState { public int AgentRunCount { get; init; } public int FailedAgentRunCount { get; init; } public string LastAgentRunSummary { get; init; } = ""; public DateTime? LastFailedAt { get; init; } public DateTime? LastCompletedAt { get; init; } } public sealed class RunDetailSummaryState { public string RunId { get; init; } = ""; public List Events { get; init; } = new(); public List FilePaths { get; init; } = new(); } public sealed class RunPlanHistoryState { public string? OriginalSummary { get; init; } public List OriginalSteps { get; init; } = new(); public string? RevisedSummary { get; init; } public List RevisedSteps { get; init; } = new(); public string? FinalApprovedSummary { get; init; } public List FinalApprovedSteps { get; init; } = new(); public bool HasAny => !string.IsNullOrWhiteSpace(OriginalSummary) || !string.IsNullOrWhiteSpace(RevisedSummary) || !string.IsNullOrWhiteSpace(FinalApprovedSummary) || OriginalSteps.Count > 0 || RevisedSteps.Count > 0 || FinalApprovedSteps.Count > 0; } public sealed class RunDisplayState { public string HeaderText { get; init; } = ""; public string MetaText { get; init; } = ""; public string SummaryText { get; init; } = ""; } public sealed class DraftQueueSummaryState { public int TotalCount { get; init; } public int QueuedCount { get; init; } public int RunningCount { get; init; } public int BlockedCount { get; init; } public int FailedCount { get; init; } public int CompletedCount { get; init; } public DraftQueueItem? NextItem { get; init; } public DateTime? NextReadyAt { get; init; } } public sealed class OperationalStatusState { public bool ShowRuntimeBadge { get; init; } public string RuntimeLabel { get; init; } = ""; public bool ShowLastCompleted { get; init; } public string LastCompletedText { get; init; } = ""; public string StripKind { get; init; } = "none"; public string StripText { get; init; } = ""; } public sealed class OperationalStatusPresentationState { public bool ShowRuntimeBadge { get; init; } public string RuntimeLabel { get; init; } = ""; public bool ShowLastCompleted { get; init; } public string LastCompletedText { get; init; } = ""; public bool ShowCompactStrip { get; init; } public string StripKind { get; init; } = "none"; public string StripText { get; init; } = ""; public string StripBackgroundHex { get; init; } = ""; public string StripBorderHex { get; init; } = ""; public string StripForegroundHex { get; init; } = ""; public bool ShowQuickStrip { get; init; } public string QuickRunningText { get; init; } = ""; public string QuickHotText { get; init; } = ""; public bool QuickRunningActive { get; init; } public bool QuickHotActive { get; init; } public string QuickRunningBackgroundHex { get; init; } = "#F8FAFC"; public string QuickRunningBorderHex { get; init; } = "#E5E7EB"; public string QuickRunningForegroundHex { get; init; } = "#6B7280"; public string QuickHotBackgroundHex { get; init; } = "#F8FAFC"; public string QuickHotBorderHex { get; init; } = "#E5E7EB"; public string QuickHotForegroundHex { get; init; } = "#6B7280"; } public ChatSessionStateService? ChatSession { get; private set; } public SkillCatalogState Skills { get; } = new(); public McpCatalogState Mcp { get; } = new(); public PermissionPolicyState Permissions { get; } = new(); public AgentCatalogState AgentCatalog { get; } = new(); public TaskRunService TaskRuns { get; } = new(); public BackgroundJobService BackgroundJobs { get; } = new(); public TaskRunStore TaskStore => TaskRuns.Store; public IReadOnlyList ActiveTasks => TaskRuns.ActiveTasks; public IReadOnlyList RecentTasks => TaskRuns.RecentTasks; public AgentRunState AgentRun { get; } = new(); public List AgentRunHistory { get; } = new(); public List PermissionHistory { get; } = new(); public List HookHistory { get; } = new(); public event Action? StateChanged; public AppStateService() { TaskRuns.Changed += NotifyStateChanged; BackgroundJobs.Changed += NotifyStateChanged; } public void AttachChatSession(ChatSessionStateService session) { ChatSession = session; NotifyStateChanged(); } public void LoadFromSettings(SettingsService settings) { var llm = settings.Settings.Llm; Skills.Enabled = llm.EnableSkillSystem; Skills.FolderPath = llm.SkillsFolderPath ?? ""; Permissions.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); Permissions.AgentDecisionLevel = llm.AgentDecisionLevel ?? "detailed"; Permissions.ToolOverrideCount = llm.ToolPermissions?.Count ?? 0; Permissions.ToolOverrides = llm.ToolPermissions? .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .ToList() ?? new List>(); var servers = llm.McpServers ?? []; Mcp.ConfiguredServerCount = servers.Count; Mcp.EnabledServerCount = servers.Count(s => s.Enabled); NotifyStateChanged(); } public void RefreshSkillCatalog(IEnumerable skills, string? folderPath, bool enabled) { var snapshot = skills.ToList(); Skills.Enabled = enabled; Skills.FolderPath = folderPath ?? ""; Skills.LoadedCount = snapshot.Count; Skills.AvailableCount = snapshot.Count(s => s.IsAvailable); Skills.UnavailableCount = snapshot.Count - Skills.AvailableCount; NotifyStateChanged(); } public void RefreshMcpCatalog(IEnumerable? servers, int connectedServerCount, int registeredToolCount) { var snapshot = servers?.ToList() ?? []; Mcp.ConfiguredServerCount = snapshot.Count; Mcp.EnabledServerCount = snapshot.Count(s => s.Enabled); Mcp.ConnectedServerCount = connectedServerCount; Mcp.RegisteredToolCount = registeredToolCount; NotifyStateChanged(); } public void RefreshAgentCatalog(ToolRegistry registry) { var tools = registry.All.ToList(); AgentCatalog.RegisteredToolCount = tools.Count; AgentCatalog.McpToolCount = tools.Count(t => t is McpTool); AgentCatalog.SkillToolCount = tools.Count(t => t.GetType().Name.EndsWith("Skill", StringComparison.OrdinalIgnoreCase)); NotifyStateChanged(); } public void UpsertTask(string id, string kind, string title, string summary, string status = "running", string? filePath = null) { TaskRuns.StartOrUpdate(id, kind, title, summary, status, filePath); } public void CompleteTask(string id, string? summary = null, string status = "completed") { TaskRuns.Complete(id, summary, status); } public void ClearTasksByPrefix(string prefix, string? summary = null, string status = "completed") { TaskRuns.CompleteByPrefix(prefix, summary, status); } public void UpsertAgentRun(string runId, string status, string summary, int iteration) { if (!string.Equals(AgentRun.RunId, runId, StringComparison.OrdinalIgnoreCase)) { AgentRun.RunId = runId; AgentRun.StartedAt = DateTime.Now; } AgentRun.Status = status; AgentRun.Summary = summary; AgentRun.LastIteration = iteration; AgentRun.UpdatedAt = DateTime.Now; NotifyStateChanged(); } public void CompleteAgentRun(string runId, string status, string summary, int iteration) { if (!string.IsNullOrWhiteSpace(runId)) AgentRun.RunId = runId; AgentRun.Status = status; AgentRun.Summary = summary; AgentRun.LastIteration = iteration; AgentRun.UpdatedAt = DateTime.Now; AgentRunHistory.Insert(0, new AgentRunState { RunId = AgentRun.RunId, Status = AgentRun.Status, Summary = AgentRun.Summary, LastIteration = AgentRun.LastIteration, StartedAt = AgentRun.StartedAt, UpdatedAt = AgentRun.UpdatedAt, }); if (AgentRunHistory.Count > 12) AgentRunHistory.RemoveRange(12, AgentRunHistory.Count - 12); NotifyStateChanged(); } public void RestoreAgentRunHistory(IEnumerable? history) { AgentRunHistory.Clear(); if (history != null) { foreach (var item in history .OrderByDescending(x => x.UpdatedAt) .Take(12)) { AgentRunHistory.Add(new AgentRunState { RunId = item.RunId, Status = item.Status, Summary = item.Summary, LastIteration = item.LastIteration, StartedAt = item.StartedAt, UpdatedAt = item.UpdatedAt, }); } } NotifyStateChanged(); } public void RestoreCurrentAgentRun( IEnumerable? executionEvents, IEnumerable? runHistory) { AgentRun.RunId = ""; AgentRun.Status = "idle"; AgentRun.Summary = ""; AgentRun.LastIteration = 0; AgentRun.StartedAt = DateTime.Now; AgentRun.UpdatedAt = DateTime.Now; var latestEvent = executionEvents? .Where(evt => !string.IsNullOrWhiteSpace(evt.RunId)) .OrderByDescending(evt => evt.Timestamp) .ThenByDescending(evt => evt.Iteration) .FirstOrDefault(); if (latestEvent != null) { var isTerminal = string.Equals(latestEvent.Type, nameof(AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) || string.Equals(latestEvent.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase); if (!isTerminal) { AgentRun.RunId = latestEvent.RunId; AgentRun.Status = "running"; AgentRun.Summary = string.IsNullOrWhiteSpace(latestEvent.Summary) ? "실행 복원됨" : latestEvent.Summary; AgentRun.LastIteration = latestEvent.Iteration; AgentRun.StartedAt = latestEvent.Timestamp == default ? DateTime.Now : latestEvent.Timestamp; AgentRun.UpdatedAt = latestEvent.Timestamp == default ? DateTime.Now : latestEvent.Timestamp; NotifyStateChanged(); return; } } var latestRun = runHistory? .OrderByDescending(GetRunSortTicks) .FirstOrDefault(); if (latestRun != null) { AgentRun.RunId = latestRun.RunId; AgentRun.Status = latestRun.Status; AgentRun.Summary = latestRun.Summary; AgentRun.LastIteration = latestRun.LastIteration; AgentRun.StartedAt = latestRun.StartedAt == default ? DateTime.Now : latestRun.StartedAt; AgentRun.UpdatedAt = latestRun.UpdatedAt == default ? DateTime.Now : latestRun.UpdatedAt; } NotifyStateChanged(); } public void RestoreRecentTasks(IEnumerable? history) { TaskRuns.RestoreRecentFromExecutionEvents(history); } public void ApplyAgentEvent(AgentEvent evt) { TaskRuns.ApplyAgentEvent(evt); if (evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied) AppendPermissionEvent(evt); if (evt.Type is AgentEventType.HookResult) AppendHookEvent(evt); switch (evt.Type) { case AgentEventType.Thinking: UpsertAgentRun( evt.RunId, "running", string.IsNullOrWhiteSpace(evt.Summary) ? "응답을 준비하는 중" : evt.Summary, evt.Iteration); break; case AgentEventType.Planning: UpsertAgentRun( evt.RunId, "running", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 계획을 세우는 중" : evt.Summary, evt.Iteration); break; case AgentEventType.Complete: CompleteAgentRun( evt.RunId, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, evt.Iteration); break; case AgentEventType.Error: if (string.IsNullOrWhiteSpace(evt.ToolName)) { CompleteAgentRun( evt.RunId, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, evt.Iteration); } break; case AgentEventType.Paused: UpsertAgentRun( evt.RunId, "paused", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 일시중지" : evt.Summary, evt.Iteration); break; case AgentEventType.Resumed: UpsertAgentRun( evt.RunId, "running", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 재개" : evt.Summary, evt.Iteration); break; } } public void ApplySubAgentStatus(SubAgentStatusEvent evt) { TaskRuns.ApplySubAgentStatus(evt); var id = string.IsNullOrWhiteSpace(evt.Id) ? "background:subagent:unknown" : $"background:subagent:{evt.Id}"; var title = string.IsNullOrWhiteSpace(evt.Id) ? "sub-agent" : evt.Id; var summary = string.IsNullOrWhiteSpace(evt.Summary) ? evt.Task : evt.Summary; switch (evt.Status) { case SubAgentRunStatus.Started: BackgroundJobs.Upsert(id, "subagent", title, summary, "running"); break; case SubAgentRunStatus.Completed: BackgroundJobs.Complete(id, summary, "completed"); break; case SubAgentRunStatus.Failed: BackgroundJobs.Complete(id, summary, "failed"); break; } } public TaskSummaryState GetTaskSummary() { var taskSummary = TaskRuns.GetSummary(); return new TaskSummaryState { ActiveCount = taskSummary.ActiveCount, PendingPermissionCount = taskSummary.PendingPermissionCount, LatestRecentTask = taskSummary.LatestRecentTask, LatestFailedRun = AgentRunHistory.FirstOrDefault(run => string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase)), }; } public AgentRunState? GetLatestFailedRun() => AgentRunHistory.FirstOrDefault(run => string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase)); public IReadOnlyList GetRecentPermissionEvents(int take = 6) => PermissionHistory .Take(Math.Max(0, take)) .ToList(); public PermissionEventState? GetLatestDeniedPermission() => PermissionHistory.FirstOrDefault(x => string.Equals(x.Status, "denied", StringComparison.OrdinalIgnoreCase)); public IReadOnlyList GetRecentHookEvents(int take = 6) => HookHistory .Take(Math.Max(0, take)) .ToList(); public BackgroundJobSummaryState GetBackgroundJobSummary() => new() { ActiveCount = BackgroundJobs.ActiveJobs.Count, LatestRecent = BackgroundJobs.RecentJobs.FirstOrDefault(), }; public IReadOnlyList GetRecentBackgroundJobs(int take = 6) => BackgroundJobs.RecentJobs .Take(Math.Max(0, take)) .ToList(); public IReadOnlyList GetActiveBackgroundJobs(int take = 6) => BackgroundJobs.ActiveJobs .Take(Math.Max(0, take)) .ToList(); public PermissionSummaryState GetPermissionSummary(ChatConversation? conversation = null) { var effective = PermissionModeCatalog.NormalizeGlobalMode(conversation?.Permission); var defaultMode = PermissionModeCatalog.NormalizeGlobalMode(Permissions.FilePermission); if (string.IsNullOrWhiteSpace(conversation?.Permission)) effective = defaultMode; var risk = PermissionModeCatalog.IsBypassPermissions(effective) ? "critical" : PermissionModeCatalog.IsAcceptEdits(effective) ? "high" : string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) ? "locked" : string.Equals(effective, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase) ? "guarded" : "normal"; var description = effective switch { "AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.", "Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.", "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", "BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.", }; return new PermissionSummaryState { EffectiveMode = effective, DefaultMode = defaultMode, OverrideCount = Permissions.ToolOverrideCount, RiskLevel = risk, Description = description, TopOverrides = Permissions.ToolOverrides.Take(4).ToList(), }; } public OperationalStatusState GetOperationalStatus(string tab) { var taskSummary = GetTaskSummary(); var queueSummary = GetDraftQueueSummary(tab); var backgroundSummary = GetBackgroundJobSummary(); var latestDeniedPermission = GetLatestDeniedPermission(); var showRuntimeBadge = taskSummary.HasActiveTasks || taskSummary.PendingPermissionCount > 0 || backgroundSummary.ActiveCount > 0; var runtimeLabel = taskSummary.PendingPermissionCount > 0 ? $"승인 대기 {taskSummary.PendingPermissionCount}" : taskSummary.HasActiveTasks ? $"실행 중 {taskSummary.ActiveCount}" : backgroundSummary.ActiveCount > 0 ? $"백그라운드 {backgroundSummary.ActiveCount}" : "실행 중 0"; var lastCompletedText = taskSummary.LatestRecentTask == null ? "" : $"마지막 {taskSummary.LatestRecentTask.Status}: {Truncate(taskSummary.LatestRecentTask.Summary, 44)}"; string stripKind; string stripText; if (taskSummary.HasActiveTasks) { stripKind = taskSummary.PendingPermissionCount > 0 ? "permission_waiting" : "running"; stripText = taskSummary.PendingPermissionCount > 0 ? $"권한 대기 {taskSummary.PendingPermissionCount}" : $"진행 중 {taskSummary.ActiveCount}"; } else if (taskSummary.LatestFailedRun != null) { stripKind = "failed_run"; stripText = $"최근 실패 {FormatDate(taskSummary.LatestFailedRun.UpdatedAt)}"; } else if (backgroundSummary.ActiveCount > 0) { stripKind = "background"; stripText = backgroundSummary.LatestRecent == null ? $"백그라운드 작업 {backgroundSummary.ActiveCount}" : $"백그라운드 작업 {backgroundSummary.ActiveCount} · 최근 {Truncate(backgroundSummary.LatestRecent.Title, 18)}"; } else if (latestDeniedPermission != null) { stripKind = "permission_denied"; stripText = $"최근 권한 거부 · {Truncate(latestDeniedPermission.ToolName, 18)}"; } else { stripKind = "none"; stripText = ""; } return new OperationalStatusState { ShowRuntimeBadge = showRuntimeBadge, RuntimeLabel = runtimeLabel, ShowLastCompleted = taskSummary.LatestRecentTask != null, LastCompletedText = lastCompletedText, StripKind = stripKind, StripText = stripText, }; } public DraftQueueSummaryState GetDraftQueueSummary(string tab) { var summary = ChatSession?.GetDraftQueueSummary(tab); if (summary == null) return new DraftQueueSummaryState(); return new DraftQueueSummaryState { TotalCount = summary.TotalCount, QueuedCount = summary.QueuedCount, RunningCount = summary.RunningCount, BlockedCount = summary.BlockedCount, FailedCount = summary.FailedCount, CompletedCount = summary.CompletedCount, NextItem = summary.NextItem, NextReadyAt = summary.NextReadyAt, }; } public OperationalStatusPresentationState GetOperationalStatusPresentation( string tab, bool hasLiveRuntimeActivity, int runningConversationCount, int spotlightConversationCount, bool runningOnlyFilter, bool sortConversationsByRecent) { var status = GetOperationalStatus(tab); return OperationalStatusPresentationCatalog.Resolve( status, tab, hasLiveRuntimeActivity, runningConversationCount, spotlightConversationCount, runningOnlyFilter, sortConversationsByRecent); } public IReadOnlyList GetDraftQueueItems(string tab) => ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty(); public IReadOnlyList GetRecentAgentRuns(int take = 4) => AgentRunHistory .Take(Math.Max(0, take)) .ToList(); public AgentRunState? GetAgentRunById(string? runId) { if (string.IsNullOrWhiteSpace(runId)) return null; if (string.Equals(AgentRun.RunId, runId, StringComparison.OrdinalIgnoreCase)) return AgentRun; return AgentRunHistory.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.OrdinalIgnoreCase)); } public AgentRunState? GetLatestConversationRun(IEnumerable? history) { var latestRunId = history? .OrderByDescending(GetRunSortTicks) .FirstOrDefault()? .RunId; return GetAgentRunById(latestRunId); } public ConversationRunSummaryState GetConversationRunSummary(IEnumerable? history) { var runs = (history ?? []) .OrderByDescending(GetRunSortTicks) .ToList(); var lastFailedRun = runs .Where(x => string.Equals(x.Status, "failed", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(GetRunSortTicks) .FirstOrDefault(); var lastCompletedRun = runs .Where(x => string.Equals(x.Status, "completed", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(GetRunSortTicks) .FirstOrDefault(); return new ConversationRunSummaryState { AgentRunCount = runs.Count, FailedAgentRunCount = runs.Count(x => string.Equals(x.Status, "failed", StringComparison.OrdinalIgnoreCase)), LastAgentRunSummary = runs.FirstOrDefault()?.Summary ?? "", LastFailedAt = lastFailedRun?.UpdatedAt, LastCompletedAt = lastCompletedRun?.UpdatedAt, }; } public RunDetailSummaryState GetRunDetailSummary(IEnumerable? history, string? runId, int eventTake = 3, int fileTake = 2) { if (string.IsNullOrWhiteSpace(runId)) return new RunDetailSummaryState(); var events = history? .Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(evt => evt.Timestamp) .Take(Math.Max(0, eventTake)) .ToList() ?? new List(); var files = history? .Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(evt.FilePath)) .OrderByDescending(evt => evt.Timestamp) .Select(evt => evt.FilePath!) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(Math.Max(0, fileTake)) .ToList() ?? new List(); return new RunDetailSummaryState { RunId = runId, Events = events, FilePaths = files, }; } public RunPlanHistoryState GetRunPlanHistory(IEnumerable? history, string? runId) { if (string.IsNullOrWhiteSpace(runId)) return new RunPlanHistoryState(); var runEvents = history? .Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase)) .OrderBy(evt => evt.Timestamp) .ThenBy(evt => evt.Iteration) .ToList() ?? new List(); var planning = runEvents .Where(evt => string.Equals(evt.Type, "Planning", StringComparison.OrdinalIgnoreCase)) .ToList(); var decisions = runEvents .Where(evt => string.Equals(evt.Type, "Decision", StringComparison.OrdinalIgnoreCase)) .ToList(); var original = planning.FirstOrDefault(); var revised = planning.Skip(1).LastOrDefault(); var approved = decisions .LastOrDefault(evt => evt.Summary.Contains("계획 승인", StringComparison.OrdinalIgnoreCase)); var originalSteps = NormalizePlanSteps(original?.Steps); var revisedSteps = NormalizePlanSteps(revised?.Steps); var approvedSteps = NormalizePlanSteps(approved?.Steps); if (approvedSteps.Count == 0) approvedSteps = revisedSteps.Count > 0 ? revisedSteps.ToList() : originalSteps.ToList(); return new RunPlanHistoryState { OriginalSummary = original?.Summary, OriginalSteps = originalSteps, RevisedSummary = revised?.Summary, RevisedSteps = revisedSteps, FinalApprovedSummary = approved?.Summary, FinalApprovedSteps = approvedSteps, }; } public RunDisplayState GetRunDisplay(AgentRunState run) { return new RunDisplayState { HeaderText = $"run {ShortRunId(run.RunId)} · {GetRunStatusLabel(run.Status)}", MetaText = $"iteration {run.LastIteration} · {run.UpdatedAt:HH:mm:ss}", SummaryText = string.IsNullOrWhiteSpace(run.Summary) ? "요약 없음" : run.Summary, }; } public string FormatExecutionEventLine(ChatExecutionEvent evt, int maxSummaryLength = 52) { var label = evt.Type switch { "PermissionRequest" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 요청" : $"{evt.ToolName} 권한 요청", "PermissionGranted" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 승인" : $"{evt.ToolName} 권한 승인", "PermissionDenied" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 거부" : $"{evt.ToolName} 권한 거부", "ToolCall" => string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 호출" : $"{evt.ToolName} 호출", "ToolResult" => string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 결과" : $"{evt.ToolName} 결과", "Error" => "오류", "Complete" => "완료", "Planning" => "계획", "Decision" => ResolveDecisionLineLabel(evt.Summary), "Thinking" => "생각", _ => evt.Type, }; var summary = string.IsNullOrWhiteSpace(evt.Summary) ? label : $"{label} · {Truncate(evt.Summary, maxSummaryLength)}"; return $"{evt.Timestamp:HH:mm:ss} · {summary}"; } public string FormatPermissionEventLine(PermissionEventState evt, int maxSummaryLength = 64) { var label = evt.Status switch { "granted" => "권한 승인", "denied" => "권한 거부", _ => "권한 요청", }; var tool = string.IsNullOrWhiteSpace(evt.ToolName) ? label : $"{evt.ToolName} · {label}"; var summary = string.IsNullOrWhiteSpace(evt.Summary) ? tool : $"{tool} · {Truncate(evt.Summary, maxSummaryLength)}"; return $"{evt.Timestamp:HH:mm:ss} · {summary}"; } public string FormatHookEventLine(HookEventState evt, int maxSummaryLength = 72) { var tool = string.IsNullOrWhiteSpace(evt.ToolName) ? "hook" : $"{evt.ToolName} hook"; var status = evt.Success ? "성공" : "실패"; var summary = string.IsNullOrWhiteSpace(evt.Summary) ? $"{tool} · {status}" : $"{tool} · {status} · {Truncate(evt.Summary, maxSummaryLength)}"; return $"{evt.Timestamp:HH:mm:ss} · {summary}"; } public string GetRunStatusLabel(string? status) => status switch { "completed" => "완료", "failed" => "실패", "paused" => "일시중지", _ => "진행 중", }; private static long GetRunSortTicks(ChatAgentRunRecord run) { var when = run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt; return when.Ticks; } private static string GetAgentTaskKey(AgentEvent evt) => string.IsNullOrWhiteSpace(evt.RunId) ? "agent:main" : $"agent:{evt.RunId}"; private static string ShortRunId(string? runId) { if (string.IsNullOrWhiteSpace(runId)) return "main"; return runId.Length <= 8 ? runId : runId[..8]; } private static string FormatDate(DateTime value) { var span = DateTime.Now - value; if (span.TotalMinutes < 1) return "방금"; if (span.TotalHours < 1) return $"{Math.Max(1, (int)span.TotalMinutes)}분 전"; if (span.TotalDays < 1) return $"{Math.Max(1, (int)span.TotalHours)}시간 전"; return value.ToString("MM-dd HH:mm"); } private static string Truncate(string? text, int max) { if (string.IsNullOrEmpty(text)) return ""; return text.Length <= max ? text : text[..max] + "…"; } private static List NormalizePlanSteps(IEnumerable? steps) { if (steps == null) return new List(); return steps .Select(step => step?.Trim() ?? "") .Where(step => !string.IsNullOrWhiteSpace(step)) .ToList(); } private static string ResolveDecisionLineLabel(string? summary) { var text = summary?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(text)) return "의사결정"; if (text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase) || text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase)) return "계획 승인 대기"; if (text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase)) return "계획 승인"; if (text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase) || text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase) || text.Contains("취소", StringComparison.OrdinalIgnoreCase)) return "계획 반려"; return "의사결정"; } private static string GetToolTaskKey(AgentEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"tool:{suffix}" : $"tool:{evt.RunId}:{suffix}"; } private static string GetPermissionTaskKey(AgentEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"permission:{suffix}" : $"permission:{evt.RunId}:{suffix}"; } private static TaskRunStore.TaskRun? TryCreateTaskFromExecutionEvent(ChatExecutionEvent item) { static string BuildScopedId(string prefix, ChatExecutionEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "main" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"{prefix}:{suffix}" : $"{prefix}:{evt.RunId}:{suffix}"; } if (string.Equals(item.Type, nameof(AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("permission", item), Kind = "permission", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "권한" : $"{item.ToolName} 권한", Summary = item.Summary, Status = string.Equals(item.Type, nameof(AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } if (string.Equals(item.Type, nameof(AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("tool", item), Kind = "tool", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "tool" : item.ToolName, Summary = item.Summary, Status = item.Success ? "completed" : "failed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } if (string.Equals(item.Type, nameof(AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("agent", item), Kind = "agent", Title = "main", Summary = item.Summary, Status = string.Equals(item.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } return null; } private void AppendPermissionEvent(AgentEvent evt) { var status = evt.Type switch { AgentEventType.PermissionGranted => "granted", AgentEventType.PermissionDenied => "denied", _ => "requested", }; PermissionHistory.Insert(0, new PermissionEventState { RunId = evt.RunId, ToolName = evt.ToolName ?? "", Status = status, Summary = evt.Summary ?? "", Timestamp = evt.Timestamp, }); if (PermissionHistory.Count > 20) PermissionHistory.RemoveRange(20, PermissionHistory.Count - 20); } private void AppendHookEvent(AgentEvent evt) { HookHistory.Insert(0, new HookEventState { RunId = evt.RunId, ToolName = evt.ToolName ?? "", Success = evt.Success, Summary = evt.Summary ?? "", Timestamp = evt.Timestamp, }); if (HookHistory.Count > 20) HookHistory.RemoveRange(20, HookHistory.Count - 20); } private void NotifyStateChanged() => StateChanged?.Invoke(); }