스킬 런타임 2차 고도화와 도구 노출 필터 정비

프로젝트 .claude/skills 재귀 로드와 namespaced SKILL.md 파싱을 추가하고 번들/사용자/프로젝트 스킬을 함께 노출하도록 SkillService와 설정 UI를 확장했다.

슬래시 스킬 호출 시 인자 치환, 스킬 폴더 변수 치환, inline shell 실행, when_to_use 기반 자동 스킬 가이드를 실제 ChatWindow 런타임 경로에 연결했다.

blanket deny 권한은 모델 노출 전 활성 도구 목록에서 먼저 제외하도록 AgentLoopService를 보강했고 관련 테스트와 README/DEVELOPMENT 문서를 업데이트했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\ (경고 0 / 오류 0)
검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\ (통과 16, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지)
This commit is contained in:
2026-04-14 18:10:16 +09:00
parent 8cb08576d5
commit b17c865c4e
12 changed files with 805 additions and 81 deletions

View File

@@ -78,7 +78,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Hooks.Should().Contain("lint-pre");
parsed.Hooks.Should().Contain("verify-pre");
@@ -119,7 +119,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Hooks.Should().Contain("lint-pre");
parsed.Hooks.Should().Contain("verify-post");
@@ -153,7 +153,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.IsSample.Should().BeTrue();
}
@@ -162,4 +162,103 @@ public class SkillServiceRuntimePolicyTests
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public void ParseSkillFile_AssignsNamespacedName_ForProjectSkillFolder()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-namespace-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "release", "notes");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
description: release notes helper
---
body
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath, Path.Combine(tempDir, ".claude", "skills")]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Name.Should().Be("release:notes");
parsed.SourceScope.Should().Be("project");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public async Task BuildSlashInvocationAsync_ReplacesArguments_AndSkillDirectoryTokens()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-args-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "release");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
argument-hint: <version> <audience>
---
Release $version for $audience from ${AX_SKILL_DIR}
Raw: $ARGUMENTS
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
SkillService.LoadSkills(projectRoot: tempDir);
var compiled = await SkillService.BuildSlashInvocationAsync("/release 1.2.3 qa-team", tempDir);
compiled.Should().NotBeNull();
compiled!.Skill.Name.Should().Be("release");
compiled.SystemPrompt.Should().Contain("Release 1.2.3 for qa-team");
compiled.SystemPrompt.Should().Contain(skillDir.Replace('\\', '/'));
compiled.SystemPrompt.Should().Contain("Raw: 1.2.3 qa-team");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public void GetAutoSkills_IncludesWhenToUseDrivenSkills()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-auto-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "triage");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
user-invocable: false
tabs: code
when_to_use: build failure test error
---
Investigate failures.
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
SkillService.LoadSkills(projectRoot: tempDir);
var autoSkills = SkillService.GetAutoSkills("Code");
autoSkills.Should().Contain(skill => skill.Name == "triage");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
}