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:
289
src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs
Normal file
289
src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user