claw-code 동등 품질 4단계 연속 반영: Agentic 루프/상태복원/설정연동/릴리즈 게이트 정렬
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:
2026-04-03 20:16:23 +09:00
parent 3b03b18f83
commit 2c047d062d
36 changed files with 1857 additions and 17 deletions

View File

@@ -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();

View 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 { }
}
}
}

View File

@@ -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();
}
}