리플레이/권한/도구 패리티 안정화: StopRequested·Paused/Resumed 반영 및 검증 보강
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- 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 필터 테스트 통과
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using AxCopilot.Services.Agent;
|
using AxCopilot.Services.Agent;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -164,4 +165,39 @@ public class AgentParityToolsTests
|
|||||||
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,4 +138,20 @@ public class OperationModePolicyTests
|
|||||||
allowed.Should().BeFalse();
|
allowed.Should().BeFalse();
|
||||||
askCalled.Should().BeTrue();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ public class TaskRunServiceTests
|
|||||||
{
|
{
|
||||||
var service = new TaskRunService();
|
var service = new TaskRunService();
|
||||||
service.StartOrUpdate("agent:1", "agent", "main", "thinking");
|
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");
|
service.Complete("agent:1", "done", "completed");
|
||||||
|
|
||||||
var summary = service.GetSummary();
|
var summary = service.GetSummary();
|
||||||
@@ -308,4 +308,62 @@ public class TaskRunServiceTests
|
|||||||
summary.LatestRecentTask.Should().NotBeNull();
|
summary.LatestRecentTask.Should().NotBeNull();
|
||||||
summary.LatestRecentTask!.Id.Should().Be("agent:1");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,24 @@ public sealed class TaskRunService
|
|||||||
case Agent.AgentEventType.Planning:
|
case Agent.AgentEventType.Planning:
|
||||||
StartAgentRun(evt.RunId, evt.Summary);
|
StartAgentRun(evt.RunId, evt.Summary);
|
||||||
break;
|
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:
|
case Agent.AgentEventType.Complete:
|
||||||
CompleteAgentRun(
|
CompleteAgentRun(
|
||||||
evt.RunId,
|
evt.RunId,
|
||||||
@@ -308,6 +326,31 @@ public sealed class TaskRunService
|
|||||||
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
|
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
|
||||||
"completed");
|
"completed");
|
||||||
break;
|
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:
|
case Agent.AgentEventType.Error:
|
||||||
if (!string.IsNullOrWhiteSpace(evt.ToolName))
|
if (!string.IsNullOrWhiteSpace(evt.ToolName))
|
||||||
{
|
{
|
||||||
@@ -453,7 +496,8 @@ public sealed class TaskRunService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) ||
|
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
|
return new TaskRunStore.TaskRun
|
||||||
{
|
{
|
||||||
@@ -461,7 +505,11 @@ public sealed class TaskRunService
|
|||||||
Kind = "agent",
|
Kind = "agent",
|
||||||
Title = "main",
|
Title = "main",
|
||||||
Summary = item.Summary,
|
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,
|
StartedAt = item.Timestamp,
|
||||||
UpdatedAt = item.Timestamp,
|
UpdatedAt = item.Timestamp,
|
||||||
FilePath = item.FilePath,
|
FilePath = item.FilePath,
|
||||||
@@ -525,6 +573,25 @@ public sealed class TaskRunService
|
|||||||
return true;
|
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) ||
|
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.Planning), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -560,6 +627,9 @@ public sealed class TaskRunService
|
|||||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
if (string.Equals(item.Type, nameof(Agent.AgentEventType.StopRequested), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +642,7 @@ public sealed class TaskRunService
|
|||||||
|
|
||||||
var shouldClearAllByRun =
|
var shouldClearAllByRun =
|
||||||
string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) ||
|
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.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase)
|
||||||
&& string.IsNullOrWhiteSpace(item.ToolName));
|
&& string.IsNullOrWhiteSpace(item.ToolName));
|
||||||
|
|
||||||
@@ -608,6 +679,9 @@ public sealed class TaskRunService
|
|||||||
|
|
||||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) ||
|
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.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))
|
string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase))
|
||||||
return BuildScopedId("agent", item);
|
return BuildScopedId("agent", item);
|
||||||
|
|
||||||
@@ -698,3 +772,7 @@ public sealed class TaskRunService
|
|||||||
return delta <= TimeSpan.FromSeconds(2);
|
return delta <= TimeSpan.FromSeconds(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user