diff --git a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs index 59c0eee..02a164a 100644 --- a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs +++ b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using System.Text.Json; using AxCopilot.Services.Agent; using FluentAssertions; @@ -164,4 +165,39 @@ public class AgentParityToolsTests try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } } } + + [Fact] + [Trait("Suite", "ParityBenchmark")] + public void ToolRegistry_CreateDefault_ContainsCoreAgenticLoopTools() + { + using var registry = ToolRegistry.CreateDefault(); + var names = registry.All.Select(t => t.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var required = new[] + { + "file_read", + "file_write", + "file_edit", + "glob", + "grep", + "process", + "git_tool", + "build_run", + "test_loop", + "spawn_agent", + "wait_agents", + "todo_write", + "task_create", + "task_update", + "task_output", + "checkpoint", + "diff_preview", + "suggest_actions", + "tool_search", + "skill_manager", + }; + + foreach (var name in required) + names.Should().Contain(name); + } } diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index 7983cca..62ce38b 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -138,4 +138,20 @@ public class OperationModePolicyTests allowed.Should().BeFalse(); askCalled.Should().BeTrue(); } + + [Fact] + public async Task AgentContext_CheckToolPermissionAsync_DenyModeBlocksWriteButAllowsRead() + { + var context = new AgentContext + { + OperationMode = OperationModePolicy.ExternalMode, + Permission = "Deny" + }; + + var writeAllowed = await context.CheckToolPermissionAsync("file_write", @"E:\work\out.txt"); + var readAllowed = await context.CheckToolPermissionAsync("file_read", @"E:\work\in.txt"); + + writeAllowed.Should().BeFalse(); + readAllowed.Should().BeTrue(); + } } diff --git a/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs b/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs index 540e1b7..2dc0dfb 100644 --- a/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs +++ b/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs @@ -298,7 +298,7 @@ public class TaskRunServiceTests { var service = new TaskRunService(); service.StartOrUpdate("agent:1", "agent", "main", "thinking"); - service.StartOrUpdate("permission:1:file_write", "permission", "file_write 권한", "ask", "waiting"); + service.StartOrUpdate("permission:1:file_write", "permission", "file_write permission", "ask", "waiting"); service.Complete("agent:1", "done", "completed"); var summary = service.GetSummary(); @@ -308,4 +308,62 @@ public class TaskRunServiceTests summary.LatestRecentTask.Should().NotBeNull(); summary.LatestRecentTask!.Id.Should().Be("agent:1"); } + [Fact] + [Trait("Suite", "ReplayStability")] + public void RestoreRecentFromExecutionEvents_ResumedKeepsAgentTaskActive() + { + var service = new TaskRunService(); + var now = DateTime.Now; + + service.RestoreRecentFromExecutionEvents( + [ + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-3), RunId = "run-r1", Type = "Thinking", Summary = "thinking", Iteration = 1 }, + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-r1", Type = "Paused", Summary = "paused", Iteration = 2 }, + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-r1", Type = "Resumed", Summary = "resumed", Iteration = 3 }, + ]); + + service.ActiveTasks.Should().Contain(t => t.Kind == "agent" && t.Status == "running"); + } + + [Fact] + [Trait("Suite", "ReplayStability")] + public void RestoreRecentFromExecutionEvents_StopRequestedClearsDanglingRunScopedTasks() + { + var service = new TaskRunService(); + var now = DateTime.Now; + + service.RestoreRecentFromExecutionEvents( + [ + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-3), RunId = "run-stop", Type = "ToolCall", ToolName = "file_edit", Summary = "call", Iteration = 1 }, + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-stop", Type = "PermissionRequest", ToolName = "file_edit", Summary = "ask", Iteration = 2 }, + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-stop", Type = "StopRequested", Summary = "stop", Iteration = 3 }, + ]); + + service.ActiveTasks.Should().BeEmpty(); + service.RecentTasks.Should().Contain(t => t.Kind == "agent"); + } + + [Fact] + public void ApplyAgentEvent_StopRequestedCompletesRunAndClearsPending() + { + var service = new TaskRunService(); + service.ApplyAgentEvent(new AgentEvent + { + RunId = "run-stop-live", + Type = AgentEventType.PermissionRequest, + ToolName = "file_write", + Summary = "ask" + }); + + service.ApplyAgentEvent(new AgentEvent + { + RunId = "run-stop-live", + Type = AgentEventType.StopRequested, + Summary = "stop" + }); + + service.ActiveTasks.Should().BeEmpty(); + service.RecentTasks.Should().Contain(t => t.Kind == "agent"); + } } + diff --git a/src/AxCopilot/Services/TaskRunService.cs b/src/AxCopilot/Services/TaskRunService.cs index b334cf4..b2ef969 100644 --- a/src/AxCopilot/Services/TaskRunService.cs +++ b/src/AxCopilot/Services/TaskRunService.cs @@ -290,6 +290,24 @@ public sealed class TaskRunService 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, @@ -308,6 +326,31 @@ public sealed class TaskRunService string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : 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)) { @@ -453,7 +496,8 @@ public sealed class TaskRunService } 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.Error), StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase)) { return new TaskRunStore.TaskRun { @@ -461,7 +505,11 @@ public sealed class TaskRunService Kind = "agent", Title = "main", Summary = item.Summary, - Status = string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed", + 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, @@ -525,6 +573,25 @@ public sealed class TaskRunService 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)) { @@ -560,6 +627,9 @@ public sealed class TaskRunService 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; } @@ -572,6 +642,7 @@ public sealed class TaskRunService 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)); @@ -608,6 +679,9 @@ public sealed class TaskRunService 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); @@ -698,3 +772,7 @@ public sealed class TaskRunService return delta <= TimeSpan.FromSeconds(2); } } + + + +