Some checks failed
Release Gate / gate (push) Has been cancelled
- TaskRunService의 권한/도구/완료/오류 기본 요약 문자열에서 깨진 문자(인코딩 깨짐) 제거 - Permission/Hook/Tool 이벤트별 기본 타이틀·요약 문구를 일관된 표현으로 정리하여 replay/요약 가독성 개선 - StopRequested 기본 요약 문구가 잘못 치환되는 문제를 수정해 취소 흐름 의미를 복구 - AGENT_ROADMAP, NEXT_ROADMAP의 테스트 스냅샷을 최신 실측값(Parity 13/13, Replay 14/14, 전체 379/379)으로 동기화
780 lines
32 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|