namespace AxCopilot.Services; /// /// Executes task-state tracking behind a single service boundary. /// AppStateService and UI layers should prefer this service over direct store access. /// public sealed class TaskRunService { public sealed class TaskSummaryState { public int ActiveCount { get; init; } public int PendingPermissionCount { get; init; } public TaskRunStore.TaskRun? LatestRecentTask { get; init; } public bool HasActiveTasks => ActiveCount > 0; } private readonly TaskRunStore _store; public TaskRunService() : this(new TaskRunStore()) { } public TaskRunService(TaskRunStore store) { _store = store; _store.Changed += () => Changed?.Invoke(); } public TaskRunStore Store => _store; public IReadOnlyList ActiveTasks => _store.ActiveTasks; public IReadOnlyList RecentTasks => _store.RecentTasks; public event Action? Changed; public void StartOrUpdate(string id, string kind, string title, string summary, string status = "running", string? filePath = null) { _store.Upsert(id, kind, title, summary, status, filePath); } public void Complete(string id, string? summary = null, string status = "completed") { _store.Complete(id, summary, status); } public void CompleteByPrefix(string prefix, string? summary = null, string status = "completed") { _store.CompleteByPrefix(prefix, summary, status); } public void RestoreRecent(IEnumerable? recent) { _store.RestoreRecent(recent); } public void StartAgentRun(string runId, string summary) { var evt = new Agent.AgentEvent { RunId = runId }; StartOrUpdate(GetAgentTaskKey(evt), "agent", "main", summary, "running"); } public void CompleteAgentRun(string runId, string summary, bool success) { var evt = new Agent.AgentEvent { RunId = runId }; Complete(GetAgentTaskKey(evt), summary, success ? "completed" : "failed"); } public void StartToolRun(string runId, string toolName, string summary, string? filePath = null, bool skill = false) { var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName }; StartOrUpdate( GetToolTaskKey(evt), skill ? "skill" : "tool", string.IsNullOrWhiteSpace(toolName) ? (skill ? "skill" : "tool") : toolName, summary, "running", filePath); } public void CompleteToolRun(string runId, string toolName, string summary, bool success) { var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName }; Complete(GetToolTaskKey(evt), summary, success ? "completed" : "failed"); } public void StartPermissionRequest(string runId, string toolName, string summary, string? filePath = null) { var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName }; StartOrUpdate( GetPermissionTaskKey(evt), "permission", string.IsNullOrWhiteSpace(toolName) ? "permission request" : $"{toolName} permission", summary, "waiting", filePath); } public void CompletePermissionRequest(string runId, string toolName, string summary, bool granted) { var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName }; Complete(GetPermissionTaskKey(evt), summary, granted ? "completed" : "failed"); } public void RecordHookResult(string runId, string toolName, string summary, bool success, string? filePath = null) { var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName }; StartOrUpdate( GetHookTaskKey(evt), "hook", string.IsNullOrWhiteSpace(toolName) ? "hook" : $"{toolName} hook", summary, "running", filePath); Complete(GetHookTaskKey(evt), summary, success ? "completed" : "failed"); } public void StartBackgroundRun(string id, string title, string summary) { var taskId = BuildBackgroundTaskId(id); StartOrUpdate(taskId, "background", string.IsNullOrWhiteSpace(title) ? "background" : title, summary, "running"); } public void CompleteBackgroundRun(string id, string title, string summary, bool success) { var taskId = BuildBackgroundTaskId(id); if (!ActiveTasks.Any(x => string.Equals(x.Id, taskId, StringComparison.OrdinalIgnoreCase))) { StartOrUpdate(taskId, "background", string.IsNullOrWhiteSpace(title) ? "background" : title, summary, "running"); } Complete(taskId, summary, success ? "completed" : "failed"); } public void StartQueueRun(string tab, string id, string summary) { var taskId = BuildQueueTaskId(tab, id); var title = string.IsNullOrWhiteSpace(tab) ? "queue" : $"{tab} queue"; StartOrUpdate(taskId, "queue", title, summary, "running"); } public void CompleteQueueRun(string tab, string id, string summary, string status) { var taskId = BuildQueueTaskId(tab, id); if (!ActiveTasks.Any(x => string.Equals(x.Id, taskId, StringComparison.OrdinalIgnoreCase))) { var title = string.IsNullOrWhiteSpace(tab) ? "queue" : $"{tab} queue"; StartOrUpdate(taskId, "queue", title, summary, "running"); } Complete(taskId, summary, status); } public TaskSummaryState GetSummary() { var active = ActiveTasks; return new TaskSummaryState { ActiveCount = active.Count, PendingPermissionCount = active.Count(task => string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) || string.Equals(task.Status, "waiting", StringComparison.OrdinalIgnoreCase)), LatestRecentTask = RecentTasks.FirstOrDefault(), }; } public void RestoreRecentFromExecutionEvents(IEnumerable? history) { var restored = new List(); var active = new Dictionary(StringComparer.OrdinalIgnoreCase); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var normalizedHistory = NormalizeReplayHistory(history); if (normalizedHistory.Count > 0) { foreach (var item in normalizedHistory) { var scopedId = TryGetScopedId(item); if (string.IsNullOrWhiteSpace(scopedId)) continue; if (TryCreateActiveTaskFromExecutionEvent(item, out var activeTask)) { active[scopedId] = activeTask; continue; } if (IsTerminalExecutionEvent(item)) { active.Remove(scopedId); RemoveRunScopedActiveTasks(active, item); } } foreach (var item in normalizedHistory .OrderByDescending(x => x.Timestamp) .ThenByDescending(GetReplayPriority) .ThenByDescending(x => x.Iteration) .ThenByDescending(x => x.ElapsedMs)) { var task = TryCreateTaskFromExecutionEvent(item); if (task == null || !seen.Add(task.Id)) continue; restored.Add(task); if (restored.Count >= 25) break; } } _store.RestoreState(active.Values, restored); } private static int GetReplayPriority(Models.ChatExecutionEvent evt) { if (string.Equals(evt.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase)) return 100; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase)) return 90; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase)) return 80; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase)) return 70; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.HookResult), StringComparison.OrdinalIgnoreCase)) return evt.Success ? 60 : 65; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase)) return evt.Success ? 50 : 55; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase)) return 40; if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase)) return 30; return 10; } public void ApplyAgentEvent(Agent.AgentEvent evt) { switch (evt.Type) { case Agent.AgentEventType.PermissionRequest: StartPermissionRequest( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? "waiting for permission" : evt.Summary, evt.FilePath); break; case Agent.AgentEventType.PermissionGranted: CompletePermissionRequest( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? "permission granted" : evt.Summary, true); break; case Agent.AgentEventType.PermissionDenied: CompletePermissionRequest( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? "permission denied" : evt.Summary, false); break; case Agent.AgentEventType.ToolCall: StartToolRun( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? $"{evt.ToolName} running" : evt.Summary, evt.FilePath); break; case Agent.AgentEventType.ToolResult: CompleteToolRun( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? evt.ToolName : evt.Summary, evt.Success); break; case Agent.AgentEventType.SkillCall: StartToolRun( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? $"{evt.ToolName} running" : evt.Summary, evt.FilePath, skill: true); break; case Agent.AgentEventType.HookResult: RecordHookResult( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? "hook running" : evt.Summary, evt.Success, evt.FilePath); break; case Agent.AgentEventType.Thinking: case Agent.AgentEventType.Planning: StartAgentRun(evt.RunId, evt.Summary); break; case Agent.AgentEventType.Paused: StartOrUpdate( GetAgentTaskKey(evt), "agent", "main", string.IsNullOrWhiteSpace(evt.Summary) ? "agent paused" : evt.Summary, "paused", evt.FilePath); break; case Agent.AgentEventType.Resumed: StartOrUpdate( GetAgentTaskKey(evt), "agent", "main", string.IsNullOrWhiteSpace(evt.Summary) ? "agent resumed" : evt.Summary, "running", evt.FilePath); break; case Agent.AgentEventType.Complete: CompleteAgentRun( evt.RunId, string.IsNullOrWhiteSpace(evt.Summary) ? "task completed" : evt.Summary, true); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "tool:" : $"tool:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "task completed" : evt.Summary, "completed"); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "permission:" : $"permission:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "task completed" : evt.Summary, "completed"); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "hook:" : $"hook:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "task completed" : evt.Summary, "completed"); break; case Agent.AgentEventType.StopRequested: StartOrUpdate( GetAgentTaskKey(evt), "agent", "main", string.IsNullOrWhiteSpace(evt.Summary) ? "agent stop requested" : evt.Summary, "running", evt.FilePath); CompleteAgentRun( evt.RunId, string.IsNullOrWhiteSpace(evt.Summary) ? "agent stop requested" : evt.Summary, false); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "tool:" : $"tool:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "agent stop requested" : evt.Summary, "cancelled"); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "permission:" : $"permission:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "agent stop requested" : evt.Summary, "cancelled"); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "hook:" : $"hook:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "agent stop requested" : evt.Summary, "cancelled"); break; case Agent.AgentEventType.Error: if (!string.IsNullOrWhiteSpace(evt.ToolName)) { CompleteToolRun( evt.RunId, evt.ToolName, string.IsNullOrWhiteSpace(evt.Summary) ? evt.ToolName : evt.Summary, false); } else { CompleteAgentRun( evt.RunId, string.IsNullOrWhiteSpace(evt.Summary) ? "error occurred" : evt.Summary, false); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "permission:" : $"permission:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "error occurred" : evt.Summary, "failed"); CompleteByPrefix( string.IsNullOrWhiteSpace(evt.RunId) ? "hook:" : $"hook:{evt.RunId}:", string.IsNullOrWhiteSpace(evt.Summary) ? "error occurred" : evt.Summary, "failed"); } break; } } public void ApplySubAgentStatus(Agent.SubAgentStatusEvent evt) { var taskId = string.IsNullOrWhiteSpace(evt.Id) ? "subagent:unknown" : $"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 Agent.SubAgentRunStatus.Started: StartOrUpdate(taskId, "subagent", title, summary, "running"); StartBackgroundRun(evt.Id, title, summary); break; case Agent.SubAgentRunStatus.Completed: Complete(taskId, summary, "completed"); CompleteBackgroundRun(evt.Id, title, summary, true); break; case Agent.SubAgentRunStatus.Failed: Complete(taskId, summary, "failed"); CompleteBackgroundRun(evt.Id, title, summary, false); break; } } private static string GetAgentTaskKey(Agent.AgentEvent evt) => string.IsNullOrWhiteSpace(evt.RunId) ? "agent:main" : $"agent:{evt.RunId}"; private static string GetToolTaskKey(Agent.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(Agent.AgentEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"permission:{suffix}" : $"permission:{evt.RunId}:{suffix}"; } private static string GetHookTaskKey(Agent.AgentEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"hook:{suffix}" : $"hook:{evt.RunId}:{suffix}"; } private static string BuildBackgroundTaskId(string id) => string.IsNullOrWhiteSpace(id) ? "background:unknown" : $"background:{id}"; private static string BuildQueueTaskId(string tab, string id) { var scope = string.IsNullOrWhiteSpace(tab) ? "default" : tab; var suffix = string.IsNullOrWhiteSpace(id) ? "unknown" : id; return $"queue:{scope}:{suffix}"; } private static TaskRunStore.TaskRun? TryCreateTaskFromExecutionEvent(Models.ChatExecutionEvent item) { static string BuildScopedId(string prefix, Models.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(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("permission", item), Kind = "permission", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "permission" : $"{item.ToolName} permission", Summary = item.Summary, Status = string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } if (string.Equals(item.Type, nameof(Agent.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(Agent.AgentEventType.HookResult), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("hook", item), Kind = "hook", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "hook" : $"{item.ToolName} hook", Summary = item.Summary, Status = item.Success ? "completed" : "failed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } if (string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { Id = BuildScopedId("agent", item), Kind = "agent", Title = "main", Summary = item.Summary, Status = string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase) ? "failed" : string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase) ? "cancelled" : "completed", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; } return null; } private static bool TryCreateActiveTaskFromExecutionEvent( Models.ChatExecutionEvent item, out TaskRunStore.TaskRun task) { task = new TaskRunStore.TaskRun(); if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase)) { task = new TaskRunStore.TaskRun { Id = BuildScopedId("permission", item), Kind = "permission", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "permission request" : $"{item.ToolName} permission", Summary = item.Summary, Status = "waiting", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; return true; } if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase)) { task = new TaskRunStore.TaskRun { Id = BuildScopedId("tool", item), Kind = "tool", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "tool" : item.ToolName, Summary = item.Summary, Status = "running", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; return true; } if (string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase)) { task = new TaskRunStore.TaskRun { Id = BuildScopedId("tool", item), Kind = "skill", Title = string.IsNullOrWhiteSpace(item.ToolName) ? "skill" : item.ToolName, Summary = item.Summary, Status = "running", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; return true; } if (string.Equals(item.Type, nameof(Agent.AgentEventType.Paused), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Resumed), StringComparison.OrdinalIgnoreCase)) { task = new TaskRunStore.TaskRun { Id = BuildScopedId("agent", item), Kind = "agent", Title = "main", Summary = item.Summary, Status = string.Equals(item.Type, nameof(Agent.AgentEventType.Paused), StringComparison.OrdinalIgnoreCase) ? "paused" : "running", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; return true; } if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase)) { task = new TaskRunStore.TaskRun { Id = BuildScopedId("agent", item), Kind = "agent", Title = "main", Summary = item.Summary, Status = "running", StartedAt = item.Timestamp, UpdatedAt = item.Timestamp, FilePath = item.FilePath, }; return true; } return false; } private static bool IsTerminalExecutionEvent(Models.ChatExecutionEvent item) { if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase)) return true; return false; } private static void RemoveRunScopedActiveTasks( Dictionary active, Models.ChatExecutionEvent item) { if (string.IsNullOrWhiteSpace(item.RunId)) return; var shouldClearAllByRun = string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase) || (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(item.ToolName)); if (!shouldClearAllByRun) return; var prefixes = new[] { $"agent:{item.RunId}:", $"tool:{item.RunId}:", $"permission:{item.RunId}:", $"hook:{item.RunId}:", }; var keys = active.Keys .Where(key => prefixes.Any(prefix => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) .ToList(); foreach (var key in keys) active.Remove(key); } private static string? TryGetScopedId(Models.ChatExecutionEvent item) { if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase)) return BuildScopedId("permission", item); if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase)) return BuildScopedId("tool", item); if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Paused), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Resumed), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase)) return BuildScopedId("agent", item); if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(item.ToolName)) return BuildScopedId("agent", item); return BuildScopedId("tool", item); } return null; } private static string BuildScopedId(string prefix, Models.ChatExecutionEvent evt) { var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "main" : evt.ToolName; return string.IsNullOrWhiteSpace(evt.RunId) ? $"{prefix}:{suffix}" : $"{prefix}:{evt.RunId}:{suffix}"; } private static IReadOnlyList NormalizeReplayHistory( IEnumerable? history) { if (history == null) return Array.Empty(); var ordered = history .Where(x => x != null) .OrderBy(x => x.Timestamp == default ? DateTime.MinValue : x.Timestamp) .ThenBy(x => x.Iteration) .ThenBy(x => x.ElapsedMs) .ToList(); var result = new List(ordered.Count); foreach (var item in ordered) { if (result.Count == 0) { result.Add(item); continue; } var last = result[^1]; if (!IsNearDuplicateReplayEvent(last, item)) { result.Add(item); continue; } last.Timestamp = item.Timestamp; last.ElapsedMs = Math.Max(last.ElapsedMs, item.ElapsedMs); last.InputTokens = Math.Max(last.InputTokens, item.InputTokens); last.OutputTokens = Math.Max(last.OutputTokens, item.OutputTokens); last.StepCurrent = Math.Max(last.StepCurrent, item.StepCurrent); last.StepTotal = Math.Max(last.StepTotal, item.StepTotal); last.Iteration = Math.Max(last.Iteration, item.Iteration); if (string.IsNullOrWhiteSpace(last.FilePath)) last.FilePath = item.FilePath; if (!string.IsNullOrWhiteSpace(item.Summary) && item.Summary.Length >= (last.Summary?.Length ?? 0)) last.Summary = item.Summary; if (item.Steps is { Count: > 0 }) last.Steps = item.Steps.ToList(); if (!string.IsNullOrWhiteSpace(item.ToolInput)) last.ToolInput = item.ToolInput; last.Success = last.Success && item.Success; } return result; } private static bool IsNearDuplicateReplayEvent(Models.ChatExecutionEvent left, Models.ChatExecutionEvent right) { if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal)) return false; if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase)) return false; if (left.Success != right.Success) return false; var delta = (right.Timestamp - left.Timestamp).Duration(); return delta <= TimeSpan.FromSeconds(2); } }