From 0176754fa03deae19c7e4d4ec246acf36233da6a Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 21:35:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4/=EA=B6=8C?= =?UTF-8?q?=ED=95=9C/=EB=8F=84=EA=B5=AC=20=ED=8C=A8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94:=20StopRequested=C2=B7Paused/Resume?= =?UTF-8?q?d=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskRunService에 AgentEventType.Paused/Resumed 처리 로직을 추가해 런타임 상태(일시정지/재개)가 task store에 일관되게 반영되도록 개선 - TaskRunService에 AgentEventType.StopRequested 처리 로직을 추가해 실행 중단 요청 시 agent/tool/permission/hook 범주의 run-scoped 태스크가 cancel 상태로 정리되도록 보강 - replay 복원 경로에서 StopRequested를 terminal 이벤트로 인식하도록 확장하고, TryGetScopedId/IsTerminalExecutionEvent/RemoveRunScopedActiveTasks 연계를 통해 dangling active task가 남지 않도록 수정 - OperationModePolicyTests에 Deny 모드 경계 테스트를 추가(쓰기 차단 + 읽기 허용)하여 권한 4모드 정책의 기대 동작을 명시적으로 고정 - TaskRunServiceTests에 ReplayStability 시나리오 3건 추가: (1) Paused→Resumed 후 agent active 유지, (2) StopRequested 후 dangling task 정리, (3) live StopRequested 적용 시 pending 권한/에이전트 상태 정리 - AgentParityToolsTests에 core agentic loop 도구 등록 검증 추가(file_read/write/edit, glob/grep/process, git/build/test, spawn/wait, task/todo, checkpoint/diff/suggest/tool_search/skill_manager) - 검증 수행: dotnet build AxCopilot.sln (경고 0 / 오류 0), 대상 테스트(OperationModePolicyTests/TaskRunServiceTests/AgentParityToolsTests) 통과, ReplayStability+ParityBenchmark 필터 테스트 통과 --- .../Services/AgentParityToolsTests.cs | 36 ++++++++ .../Services/OperationModePolicyTests.cs | 16 ++++ .../Services/TaskRunServiceTests.cs | 60 +++++++++++++- src/AxCopilot/Services/TaskRunService.cs | 82 ++++++++++++++++++- 4 files changed, 191 insertions(+), 3 deletions(-) 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); } } + + + +