Files
AX-Copilot-Codex/src/AxCopilot/Services/AppStateService.cs
lacvet d5c1266d3e
Some checks failed
Release Gate / gate (push) Has been cancelled
상태선/권한 카탈로그 구조 정리와 계획 모드 표현 잔재 제거
- OperationalStatusPresentationCatalog를 추가해 compact strip과 quick strip의 색상/노출 계산을 AppStateService 밖으로 분리함

- PermissionRequestPresentationCatalog와 ToolResultPresentationCatalog에 Kind/Description 메타를 추가해 transcript fallback 설명을 타입 기반으로 정리함

- PermissionModePresentationCatalog와 ChatWindow.PermissionPresentation에서 제거된 계획 모드 표현 분기를 걷어 권한 UI를 실제 지원 모드만 다루도록 단순화함

- README, DEVELOPMENT, claw-code parity plan 문서를 2026-04-06 09:36 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 09:44:53 +09:00

1042 lines
40 KiB
C#

using AxCopilot.Services.Agent;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 앱 전역에서 공유되는 상태 요약 저장소.
/// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아
/// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다.
/// </summary>
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<KeyValuePair<string, string>> 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<KeyValuePair<string, string>> TopOverrides { get; init; } = Array.Empty<KeyValuePair<string, string>>();
}
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<ChatExecutionEvent> Events { get; init; } = new();
public List<string> FilePaths { get; init; } = new();
}
public sealed class RunPlanHistoryState
{
public string? OriginalSummary { get; init; }
public List<string> OriginalSteps { get; init; } = new();
public string? RevisedSummary { get; init; }
public List<string> RevisedSteps { get; init; } = new();
public string? FinalApprovedSummary { get; init; }
public List<string> 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<TaskRunStore.TaskRun> ActiveTasks => TaskRuns.ActiveTasks;
public IReadOnlyList<TaskRunStore.TaskRun> RecentTasks => TaskRuns.RecentTasks;
public AgentRunState AgentRun { get; } = new();
public List<AgentRunState> AgentRunHistory { get; } = new();
public List<PermissionEventState> PermissionHistory { get; } = new();
public List<HookEventState> 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<KeyValuePair<string, string>>();
var servers = llm.McpServers ?? [];
Mcp.ConfiguredServerCount = servers.Count;
Mcp.EnabledServerCount = servers.Count(s => s.Enabled);
NotifyStateChanged();
}
public void RefreshSkillCatalog(IEnumerable<SkillDefinition> 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<Models.McpServerEntry>? 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<ChatAgentRunRecord>? 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<ChatExecutionEvent>? executionEvents,
IEnumerable<ChatAgentRunRecord>? 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<ChatExecutionEvent>? 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<PermissionEventState> 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<HookEventState> 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<BackgroundJobService.BackgroundJobState> GetRecentBackgroundJobs(int take = 6)
=> BackgroundJobs.RecentJobs
.Take(Math.Max(0, take))
.ToList();
public IReadOnlyList<BackgroundJobService.BackgroundJobState> 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<DraftQueueItem> GetDraftQueueItems(string tab)
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();
public IReadOnlyList<AgentRunState> 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<ChatAgentRunRecord>? history)
{
var latestRunId = history?
.OrderByDescending(GetRunSortTicks)
.FirstOrDefault()?
.RunId;
return GetAgentRunById(latestRunId);
}
public ConversationRunSummaryState GetConversationRunSummary(IEnumerable<ChatAgentRunRecord>? 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<ChatExecutionEvent>? 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<ChatExecutionEvent>();
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<string>();
return new RunDetailSummaryState
{
RunId = runId,
Events = events,
FilePaths = files,
};
}
public RunPlanHistoryState GetRunPlanHistory(IEnumerable<ChatExecutionEvent>? 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<ChatExecutionEvent>();
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<string> NormalizePlanSteps(IEnumerable<string>? steps)
{
if (steps == null)
return new List<string>();
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();
}