AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강

변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
This commit is contained in:
2026-04-14 17:52:46 +09:00
parent fa33b98f7e
commit 8cb08576d5
200 changed files with 13522 additions and 5764 deletions

View File

@@ -0,0 +1,289 @@
using System.IO;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class WorkspaceContextGeneratorTests
{
// ═══════════════════════════════════════════
// LoadContext
// ═══════════════════════════════════════════
[Fact]
public void LoadContext_NullFolder_ReturnsNull()
{
WorkspaceContextGenerator.LoadContext(null).Should().BeNull();
}
[Fact]
public void LoadContext_EmptyFolder_ReturnsNull()
{
WorkspaceContextGenerator.LoadContext("").Should().BeNull();
}
[Fact]
public void LoadContext_NonExistentFile_ReturnsNull()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// .ax-context.md가 없으면 null
WorkspaceContextGenerator.LoadContext(tempDir).Should().BeNull();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void LoadContext_ExistingFile_ReturnsContent()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
File.WriteAllText(contextPath, "# Test Context\nHello");
var result = WorkspaceContextGenerator.LoadContext(tempDir);
result.Should().NotBeNull();
result.Should().Contain("Test Context");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void LoadContext_LargeFile_Truncated()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
// 4000자 제한 초과하는 내용 작성
File.WriteAllText(contextPath, new string('A', 5000));
var result = WorkspaceContextGenerator.LoadContext(tempDir);
result.Should().NotBeNull();
result!.Should().Contain("(truncated)");
result.Length.Should().BeLessOrEqualTo(4100); // 4000 + truncation message
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// EnsureContextAsync — 멱등성
// ═══════════════════════════════════════════
[Fact]
public async Task EnsureContextAsync_NullFolder_ReturnsNull()
{
var result = await WorkspaceContextGenerator.EnsureContextAsync(null!);
result.Should().BeNull();
}
[Fact]
public async Task EnsureContextAsync_NonExistentFolder_ReturnsNull()
{
var result = await WorkspaceContextGenerator.EnsureContextAsync("/nonexistent/path/xyz");
result.Should().BeNull();
}
[Fact]
public async Task EnsureContextAsync_ExistingContextFile_ReturnsWithoutRegeneration()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
File.WriteAllText(contextPath, "# Pre-existing context");
var result = await WorkspaceContextGenerator.EnsureContextAsync(tempDir);
result.Should().Contain("Pre-existing context");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// GenerateAsync — 실제 생성
// ═══════════════════════════════════════════
[Fact]
public async Task GenerateAsync_CreatesContextFile()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// 테스트 파일 구조 생성
File.WriteAllText(Path.Combine(tempDir, "test.cs"), "class Test {}");
File.WriteAllText(Path.Combine(tempDir, "helper.cs"), "class Helper {}");
var subDir = Path.Combine(tempDir, "src");
Directory.CreateDirectory(subDir);
File.WriteAllText(Path.Combine(subDir, "main.cs"), "class Main {}");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().NotBeNullOrEmpty();
result.Should().Contain("# Workspace Context (auto-generated)");
result.Should().Contain("## Project");
result.Should().Contain("## Structure");
result.Should().Contain(".cs");
// 파일 생성 확인
File.Exists(Path.Combine(tempDir, ".ax-context.md")).Should().BeTrue();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsDotNetBuildSystem()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "MyProject.sln"), "solution content");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain(".NET");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsNodeJsBuildSystem()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "package.json"), """{"name": "test"}""");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("Node.js");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_IncludesReadmeSummary()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "README.md"),
"# My Project\n\nThis is a test project for unit testing.");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## README Summary");
result.Should().Contain("test project");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsContextFiles()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "AGENTS.md"), "Agent rules here");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## Existing Context Files");
result.Should().Contain("AGENTS.md");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_SkipsHiddenDirs()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// node_modules는 건너뛰어야 함
var nodeModules = Path.Combine(tempDir, "node_modules");
Directory.CreateDirectory(nodeModules);
File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}");
// src는 표시되어야 함
var src = Path.Combine(tempDir, "src");
Directory.CreateDirectory(src);
File.WriteAllText(Path.Combine(src, "main.cs"), "class Main {}");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("src/");
result.Should().NotContain("node_modules/");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// 취소 지원
// ═══════════════════════════════════════════
[Fact]
public async Task EnsureContextAsync_Cancellation_Throws()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
using var cts = new CancellationTokenSource();
cts.Cancel();
// 이미 취소된 토큰으로 호출 — 파일이 없으므로 GenerateAsync 진입
// OperationCanceledException 또는 정상 반환 (구현에 따라)
// 최소한 크래시하지 않으면 OK
try
{
await WorkspaceContextGenerator.EnsureContextAsync(tempDir, cts.Token);
}
catch (OperationCanceledException)
{
// 예상된 동작
}
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
}