Files
AX-Copilot-Codex/src/AxCopilot/Services/TaskRunService.cs
lacvet 9e37aad163
Some checks failed
Release Gate / gate (push) Has been cancelled
TaskRunService 문자열 정리 및 로드맵 검증 수치 동기화
- TaskRunService의 권한/도구/완료/오류 기본 요약 문자열에서 깨진 문자(인코딩 깨짐) 제거

- Permission/Hook/Tool 이벤트별 기본 타이틀·요약 문구를 일관된 표현으로 정리하여 replay/요약 가독성 개선

- StopRequested 기본 요약 문구가 잘못 치환되는 문제를 수정해 취소 흐름 의미를 복구

- AGENT_ROADMAP, NEXT_ROADMAP의 테스트 스냅샷을 최신 실측값(Parity 13/13, Replay 14/14, 전체 379/379)으로 동기화
2026-04-03 21:44:13 +09:00

780 lines
32 KiB
C#

namespace AxCopilot.Services;
/// <summary>
/// Executes task-state tracking behind a single service boundary.
/// AppStateService and UI layers should prefer this service over direct store access.
/// </summary>
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<TaskRunStore.TaskRun> ActiveTasks => _store.ActiveTasks;
public IReadOnlyList<TaskRunStore.TaskRun> 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<TaskRunStore.TaskRun>? 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<Models.ChatExecutionEvent>? history)
{
var restored = new List<TaskRunStore.TaskRun>();
var active = new Dictionary<string, TaskRunStore.TaskRun>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(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<string, TaskRunStore.TaskRun> 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<Models.ChatExecutionEvent> NormalizeReplayHistory(
IEnumerable<Models.ChatExecutionEvent>? history)
{
if (history == null)
return Array.Empty<Models.ChatExecutionEvent>();
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<Models.ChatExecutionEvent>(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);
}
}