claw-code 동등 품질 4단계 연속 반영: Agentic 루프/상태복원/설정연동/릴리즈 게이트 정렬
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- 도구 동등화: task/todo/tool-search + plan/worktree/team/cron 도구군 추가 및 ToolRegistry 등록\n- claw-code CamelCase 별칭 정규화 확장: EnterPlanMode/EnterWorktree/TeamCreate/CronCreate 등 -> 내부 snake_case 매핑\n- AgentLoop 런타임 강화: Code 탭 전용 도구 토글(CodeSettings) 반영, 비활성 도구 자동 차단\n- Worktree 상태 복원 연결: .ax/worktree_state.json 기반 루트 탐색/활성 worktree 복원 및 BuildContext 연동\n- 권한/플러그인 하드닝 기존 반영분 유지: target 기반 권한 판정 + internal 모드 플러그인 경로/manifest 검증\n- 설정 연동(UI): SettingsWindow Code 패널에 Plan/Worktree/Team/Cron 도구 on/off 토글 추가\n- 테스트 보강: AgentParityTools/AgentLoopE2E에 worktree 지속성, alias 정규화, 설정 차단 시나리오 추가\n- 검증 완료: dotnet build(경고0/오류0), ParityBenchmark 11/11, ReplayStability 12/12, 전체 371/371, release-gate 통과\n- 문서 동기화: AGENT_ROADMAP/NEXT_ROADMAP/CLAW_CODE_PARITY_PLAN 수치 및 기준 최신화
This commit is contained in:
@@ -309,6 +309,170 @@ public class AgentLoopE2ETests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_TaskCreateAlias_ResolvesAndExecutes()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-task-alias-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("TaskCreate", new { title = "Alias task", priority = "high" }, "task alias"),
|
||||
BuildTextResponse("완료"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.WorkFolder = tempDir;
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "task create alias" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("완료");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "task_create");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "task_create" && e.Success);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_EnterPlanModeAlias_ResolvesAndExecutes()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-plan-alias-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("EnterPlanMode", new { }, "plan alias"),
|
||||
BuildTextResponse("?꾨즺"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.WorkFolder = tempDir;
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "enter plan mode alias" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("?꾨즺");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "enter_plan_mode");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "enter_plan_mode" && e.Success);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WorktreeState_PersistsAcrossRuns()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-worktree-persist-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
using (var enterServer = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("EnterWorktree", new { path = "wt1", create = true }, "enter worktree"),
|
||||
BuildTextResponse("done-1"),
|
||||
]))
|
||||
{
|
||||
var enterSettings = BuildLoopSettings(enterServer.Endpoint);
|
||||
enterSettings.Settings.Llm.WorkFolder = tempDir;
|
||||
enterSettings.Settings.Llm.FilePermission = "Auto";
|
||||
using var enterLlm = new LlmService(enterSettings);
|
||||
using var enterTools = ToolRegistry.CreateDefault();
|
||||
var enterLoop = new AgentLoopService(enterLlm, enterTools, enterSettings) { ActiveTab = "Code" };
|
||||
var first = await enterLoop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "enter worktree now" }
|
||||
]);
|
||||
first.Should().Contain("done-1");
|
||||
}
|
||||
|
||||
using (var writeServer = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("TaskCreate", new { title = "persisted-task", priority = "high" }, "create task in persisted worktree"),
|
||||
BuildTextResponse("done-2"),
|
||||
]))
|
||||
{
|
||||
var writeSettings = BuildLoopSettings(writeServer.Endpoint);
|
||||
writeSettings.Settings.Llm.WorkFolder = tempDir;
|
||||
writeSettings.Settings.Llm.FilePermission = "Auto";
|
||||
using var writeLlm = new LlmService(writeSettings);
|
||||
using var writeTools = ToolRegistry.CreateDefault();
|
||||
var writeLoop = new AgentLoopService(writeLlm, writeTools, writeSettings) { ActiveTab = "Code" };
|
||||
var second = await writeLoop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "write file in active worktree" }
|
||||
]);
|
||||
second.Should().Contain("done-2");
|
||||
}
|
||||
|
||||
File.Exists(Path.Combine(tempDir, "wt1", ".ax", "taskboard.json")).Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CodeSetting_DisablesPlanModeTools()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-disable-plan-tool-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("EnterPlanMode", new { }, "attempt disabled tool"),
|
||||
BuildTextResponse("done"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.WorkFolder = tempDir;
|
||||
settings.Settings.Llm.Code.EnablePlanModeTools = false;
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "run plan mode tool" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("done");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "EnterPlanMode");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static SettingsService BuildLoopSettings(string endpoint)
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
|
||||
167
src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
Normal file
167
src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentParityToolsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TaskBoardTools_CreateUpdateOutputAndGet_ShouldRoundTrip()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-task-board-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
try
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var create = new TaskCreateTool();
|
||||
var update = new TaskUpdateTool();
|
||||
var output = new TaskOutputTool();
|
||||
var get = new TaskGetTool();
|
||||
|
||||
var created = await create.ExecuteAsync(JsonDocument.Parse("""{"title":"Implement parity","priority":"high"}""").RootElement, context);
|
||||
created.Success.Should().BeTrue();
|
||||
created.Output.Should().Contain("#1");
|
||||
|
||||
var updated = await update.ExecuteAsync(JsonDocument.Parse("""{"id":1,"status":"in_progress"}""").RootElement, context);
|
||||
updated.Success.Should().BeTrue();
|
||||
updated.Output.Should().Contain("in_progress");
|
||||
|
||||
var outResult = await output.ExecuteAsync(JsonDocument.Parse("""{"id":1,"output":"step-1 done","append":true}""").RootElement, context);
|
||||
outResult.Success.Should().BeTrue();
|
||||
|
||||
var loaded = await get.ExecuteAsync(JsonDocument.Parse("""{"id":1}""").RootElement, context);
|
||||
loaded.Success.Should().BeTrue();
|
||||
loaded.Output.Should().Contain("Implement parity");
|
||||
loaded.Output.Should().Contain("step-1 done");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TodoWriteTool_AddListDone_ShouldTrackMarkdownChecklist()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-todo-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
try
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var tool = new TodoWriteTool();
|
||||
(await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"add","text":"Check permissions"}""").RootElement, context)).Success.Should().BeTrue();
|
||||
var list = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"list"}""").RootElement, context);
|
||||
list.Success.Should().BeTrue();
|
||||
list.Output.Should().Contain("Check permissions");
|
||||
|
||||
var done = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"done","index":0}""").RootElement, context);
|
||||
done.Success.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlanModeTools_EnterThenExit_ShouldToggleStateFile()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-plan-mode-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
try
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var enter = new EnterPlanModeTool();
|
||||
var exit = new ExitPlanModeTool();
|
||||
var statePath = Path.Combine(workDir, ".ax", "plan_mode.state");
|
||||
|
||||
(await enter.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue();
|
||||
File.Exists(statePath).Should().BeTrue();
|
||||
|
||||
(await exit.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue();
|
||||
File.Exists(statePath).Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorktreeTeamCronTools_ShouldPersistState()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-worktree-team-cron-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
try
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var enterWorktree = new EnterWorktreeTool();
|
||||
var exitWorktree = new ExitWorktreeTool();
|
||||
var entered = await enterWorktree.ExecuteAsync(JsonDocument.Parse("""{"path":"feature/a","create":true}""").RootElement, context);
|
||||
entered.Success.Should().BeTrue();
|
||||
File.Exists(Path.Combine(workDir, ".ax", "worktree_state.json")).Should().BeTrue();
|
||||
context.WorkFolder.Replace('\\', '/').Should().EndWith("/feature/a");
|
||||
|
||||
var write = new FileWriteTool();
|
||||
var writeInWorktree = await write.ExecuteAsync(
|
||||
JsonDocument.Parse("""{"path":"notes.txt","content":"worktree-content"}""").RootElement,
|
||||
context,
|
||||
CancellationToken.None);
|
||||
writeInWorktree.Success.Should().BeTrue();
|
||||
File.Exists(Path.Combine(workDir, "feature", "a", "notes.txt")).Should().BeTrue();
|
||||
|
||||
var exited = await exitWorktree.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context);
|
||||
exited.Success.Should().BeTrue();
|
||||
Path.GetFullPath(context.WorkFolder).Should().Be(Path.GetFullPath(workDir));
|
||||
|
||||
var teamCreate = new TeamCreateTool();
|
||||
var teamDelete = new TeamDeleteTool();
|
||||
(await teamCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1","role":"worker"}""").RootElement, context)).Success.Should().BeTrue();
|
||||
var teamFile = Path.Combine(workDir, ".ax", "team.json");
|
||||
File.Exists(teamFile).Should().BeTrue();
|
||||
var teamJson = await File.ReadAllTextAsync(teamFile);
|
||||
teamJson.Should().Contain("worker-1");
|
||||
(await teamDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1"}""").RootElement, context)).Success.Should().BeTrue();
|
||||
|
||||
var cronCreate = new CronCreateTool();
|
||||
var cronList = new CronListTool();
|
||||
var cronDelete = new CronDeleteTool();
|
||||
(await cronCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly","schedule":"daily 02:00","command":"build"}""").RootElement, context)).Success.Should().BeTrue();
|
||||
var listed = await cronList.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context);
|
||||
listed.Success.Should().BeTrue();
|
||||
listed.Output.Should().Contain("nightly");
|
||||
(await cronDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly"}""").RootElement, context)).Success.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,47 @@ public class OperationModePolicyTests
|
||||
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
|
||||
allowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentContext_GetEffectiveToolPermission_PrefersPatternRule()
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
Permission = "Ask",
|
||||
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["process"] = "deny",
|
||||
["process@git *"] = "auto",
|
||||
["*@*.md"] = "ask",
|
||||
}
|
||||
};
|
||||
|
||||
context.GetEffectiveToolPermission("process", "git status").Should().Be("auto");
|
||||
context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("deny");
|
||||
context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("ask");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentContext_CheckToolPermissionAsync_UsesPatternRuleWithoutPrompt()
|
||||
{
|
||||
var askCalled = false;
|
||||
var context = new AgentContext
|
||||
{
|
||||
OperationMode = OperationModePolicy.ExternalMode,
|
||||
Permission = "Ask",
|
||||
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["process@git *"] = "auto",
|
||||
},
|
||||
AskPermission = (_, _) =>
|
||||
{
|
||||
askCalled = true;
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
};
|
||||
|
||||
var allowed = await context.CheckToolPermissionAsync("process", "git status");
|
||||
allowed.Should().BeTrue();
|
||||
askCalled.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user