코드탭 query/history 조립 구조를 단계형 서비스로 분리

- AgentLoopQueryAssemblyService를 추가해 session learning refresh, queued command/query window 준비, code working set supplemental context 부착을 단계형으로 정리함

- AgentLoopService는 orchestration 중심으로 단순화하고 claw-code의 staged query/history 흐름과 비슷하게 책임을 재배치함

- AgentLoopQueryAssemblyServiceTests를 추가하고 SessionLearningCollectorTests를 영어 기준으로 정리했으며 dotnet build 및 targeted dotnet test(56 통과, 경고/오류 0)로 검증함
This commit is contained in:
2026-04-16 02:07:26 +09:00
parent f0f1f76f48
commit 2e1c7be8c3
7 changed files with 255 additions and 58 deletions

View File

@@ -0,0 +1,100 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class AgentLoopQueryAssemblyServiceTests
{
[Fact]
public void PrepareHistory_ShouldInjectSessionLearningsForNonCodeTabs()
{
var messages = new List<ChatMessage>
{
new()
{
Role = "user",
Content = "fix the build"
}
};
var collector = new SessionLearningCollector();
collector.TryExtract("build_run", "error CS0246: Missing type", success: false);
var result = AgentLoopQueryAssemblyService.PrepareHistory(
messages,
new AgentCommandQueue(),
lastToolResultAtUtc: null,
lastToolResultToolName: null,
utcNow: DateTime.UtcNow,
isCodeTab: false,
sessionLearnings: collector,
queryOptions: AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateDefault());
result.RefreshedSessionLearnings.Should().BeTrue();
messages[0].MetaKind.Should().Be("session_learnings");
messages[0].Content.Should().Contain("[System:SessionLearnings]");
result.IterationPreparation.QueryView.Messages.Should().Contain(message => message.MetaKind == "session_learnings");
}
[Fact]
public void PrepareHistory_ShouldSkipSessionLearningsForCodeTabs()
{
var messages = new List<ChatMessage>
{
new()
{
Role = "user",
Content = "fix the build"
}
};
var collector = new SessionLearningCollector();
collector.TryExtract("build_run", "error CS0246: Missing type", success: false);
var result = AgentLoopQueryAssemblyService.PrepareHistory(
messages,
new AgentCommandQueue(),
lastToolResultAtUtc: null,
lastToolResultToolName: null,
utcNow: DateTime.UtcNow,
isCodeTab: true,
sessionLearnings: collector,
queryOptions: AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault());
result.RefreshedSessionLearnings.Should().BeFalse();
messages.Should().OnlyContain(message => message.MetaKind != "session_learnings");
}
[Fact]
public void PrepareRequest_ShouldAppendWorkingSetMessageForCodeRuns()
{
var queryMessages = new List<ChatMessage>
{
new()
{
Role = "user",
Content = "create the WPF shell"
}
};
var workingSet = new CodeTaskWorkingSetService(
"create the WPF shell",
@"E:\code",
"wpf-mvvm",
startedFromEmptyWorkspace: true);
using var fileManageInput = JsonDocument.Parse("""{"action":"mkdir","paths":["Views","ViewModels","Themes"]}""");
workingSet.RecordToolResult("file_manage", fileManageInput.RootElement.Clone(), ToolResult.Ok("created directories"));
var result = AgentLoopQueryAssemblyService.PrepareRequest(
queryMessages,
workingSet,
totalToolCalls: 0,
forceInitialToolCallEnabled: true,
injectPreCallToolReminder: true,
noToolCallLoopRetry: 0);
result.SupplementalMessageCount.Should().Be(1);
result.SendMessages.Should().Contain(message => message.MetaKind == "code_working_set");
result.SendMessages.Last().Content.Should().Contain("[TOOL_REQUIRED]");
}
}

View File

@@ -6,9 +6,7 @@ namespace AxCopilot.Tests.Services;
public class SessionLearningCollectorTests
{
// ═══════════════════════════════════════════
// 기본 동작
// ═══════════════════════════════════════════
// Basic behavior
[Fact]
public void Empty_BuildInjectionMessage_ReturnsNull()
@@ -36,9 +34,7 @@ public class SessionLearningCollectorTests
collector.BuildInjectionMessage().Should().BeNull();
}
// ═══════════════════════════════════════════
// 빌드/테스트 실패 추출
// ═══════════════════════════════════════════
// Build / test failure extraction
[Fact]
public void TryExtract_BuildRunFailure_ExtractsBuildConfig()
@@ -68,9 +64,7 @@ public class SessionLearningCollectorTests
collector.Count.Should().Be(1);
}
// ═══════════════════════════════════════════
// grep/glob 결과 추출
// ═══════════════════════════════════════════
// grep / glob extraction
[Fact]
public void TryExtract_GrepSuccess_ExtractsCodeLocation()
@@ -94,14 +88,11 @@ public class SessionLearningCollectorTests
public void TryExtract_GrepSingleFile_NoLearning()
{
var collector = new SessionLearningCollector();
// 단일 파일 경로만 있으면 학습 가치 없음
collector.TryExtract("grep", "src/file.cs:10: something", success: true);
collector.Count.Should().Be(0);
}
// ═══════════════════════════════════════════
// 프로젝트 메타 파일 추출
// ═══════════════════════════════════════════
// Project metadata extraction
[Fact]
public void TryExtract_CsprojRead_ExtractsProjectStructure()
@@ -128,9 +119,7 @@ public class SessionLearningCollectorTests
msg.Should().Contain("xunit");
}
// ═══════════════════════════════════════════
// 런타임 감지 추출
// ═══════════════════════════════════════════
// Environment detection extraction
[Fact]
public void TryExtract_DevEnvDetect_ExtractsDependency()
@@ -149,9 +138,7 @@ public class SessionLearningCollectorTests
msg.Should().Contain("[dependency]");
}
// ═══════════════════════════════════════════
// 파일 조작 에러 추출
// ═══════════════════════════════════════════
// File operation error extraction
[Fact]
public void TryExtract_FileWriteFailure_ExtractsErrorPattern()
@@ -165,9 +152,7 @@ public class SessionLearningCollectorTests
msg.Should().Contain("file_write");
}
// ═══════════════════════════════════════════
// FIFO 관리
// ═══════════════════════════════════════════
// FIFO management
[Fact]
public void TryExtract_FifoEviction_MaintainsMaxLimit()
@@ -175,16 +160,12 @@ public class SessionLearningCollectorTests
var collector = new SessionLearningCollector(maxLearnings: 3);
for (int i = 0; i < 5; i++)
{
collector.TryExtract("file_write", $"Error {i}: unique error message number {i}", success: false);
}
collector.Count.Should().BeLessOrEqualTo(3);
}
// ═══════════════════════════════════════════
// 중복 방지
// ═══════════════════════════════════════════
// Duplicate prevention
[Fact]
public void TryExtract_DuplicateContent_NotAdded()
@@ -198,9 +179,7 @@ public class SessionLearningCollectorTests
collector.Count.Should().Be(1);
}
// ═══════════════════════════════════════════
// BuildInjectionMessage 포맷
// ═══════════════════════════════════════════
// BuildInjectionMessage formatting
[Fact]
public void BuildInjectionMessage_ContainsHeader()
@@ -210,12 +189,10 @@ public class SessionLearningCollectorTests
var msg = collector.BuildInjectionMessage()!;
msg.Should().StartWith("[System:SessionLearnings]");
msg.Should().Contain("위 내용을 참고하여 동일 실수를 반복하지 마세요");
msg.Should().Contain("Use these notes to avoid repeating the same failed attempts.");
}
// ═══════════════════════════════════════════
// 안전 가드
// ═══════════════════════════════════════════
// Defensive argument handling
[Theory]
[InlineData(null, "output")]
@@ -227,16 +204,13 @@ public class SessionLearningCollectorTests
{
var collector = new SessionLearningCollector();
collector.TryExtract(toolName!, output!, false);
// No exception = pass
}
[Fact]
public void TryExtract_LargeOutput_TruncatesWithoutCrash()
{
var collector = new SessionLearningCollector();
// 50KB의 큰 출력
var largeOutput = "error CS0001: Big error\n" + new string('x', 60_000);
collector.TryExtract("build_run", largeOutput, success: false);
// 크래시 없이 완료 = 성공
}
}

View File

@@ -0,0 +1,85 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopQueryHistoryPreparationResult(
AgentLoopIterationPreparationResult IterationPreparation,
bool RefreshedSessionLearnings);
/// <summary>
/// Assembles the staged query/history pipeline for an AgentLoop iteration.
/// This keeps AgentLoopService focused on orchestration while the assembly
/// rules live in one place.
/// </summary>
internal static class AgentLoopQueryAssemblyService
{
public static AgentLoopQueryHistoryPreparationResult PrepareHistory(
List<ChatMessage> messages,
AgentCommandQueue pendingCommands,
DateTime? lastToolResultAtUtc,
string? lastToolResultToolName,
DateTime utcNow,
bool isCodeTab,
SessionLearningCollector? sessionLearnings,
AgentQueryContextBuilder.AgentQueryContextBuildOptions? queryOptions = null)
{
var refreshedSessionLearnings = RefreshSessionLearningsBlock(messages, isCodeTab, sessionLearnings);
var iterationPreparation = AgentLoopIterationPreparationService.Prepare(
messages,
pendingCommands,
lastToolResultAtUtc,
lastToolResultToolName,
utcNow,
queryOptions);
return new AgentLoopQueryHistoryPreparationResult(
iterationPreparation,
refreshedSessionLearnings);
}
public static AgentLoopLlmRequestPreparationResult PrepareRequest(
IReadOnlyList<ChatMessage> queryMessages,
CodeTaskWorkingSetService? codeWorkingSet,
int totalToolCalls,
bool forceInitialToolCallEnabled,
bool injectPreCallToolReminder,
int noToolCallLoopRetry)
{
var supplementalMessages = BuildSupplementalMessages(codeWorkingSet);
return AgentLoopLlmRequestPreparationService.Prepare(
queryMessages,
totalToolCalls,
forceInitialToolCallEnabled,
injectPreCallToolReminder,
noToolCallLoopRetry,
supplementalMessages);
}
internal static bool RefreshSessionLearningsBlock(
List<ChatMessage> messages,
bool isCodeTab,
SessionLearningCollector? sessionLearnings)
{
if (isCodeTab || sessionLearnings is not { Count: > 0 })
return false;
var learningMsg = sessionLearnings.BuildInjectionMessage();
if (string.IsNullOrWhiteSpace(learningMsg))
return false;
messages.RemoveAll(message => string.Equals(message.MetaKind, "session_learnings", StringComparison.OrdinalIgnoreCase));
messages.Insert(0, new ChatMessage
{
Role = "user",
Content = learningMsg,
MetaKind = "session_learnings",
});
return true;
}
private static IEnumerable<ChatMessage>? BuildSupplementalMessages(CodeTaskWorkingSetService? codeWorkingSet)
{
var workingSetMessage = codeWorkingSet?.BuildChatMessage();
return workingSetMessage is null ? null : [workingSetMessage];
}
}

View File

@@ -458,13 +458,16 @@ public partial class AgentLoopService
}
}
var iterationPreparation = AgentLoopIterationPreparationService.Prepare(
var historyPreparation = AgentLoopQueryAssemblyService.PrepareHistory(
messages,
_pendingCommands,
lastToolResultAtUtc,
lastToolResultToolName,
DateTime.UtcNow,
isCodeTab,
sessionLearnings,
queryContextOptions);
var iterationPreparation = historyPreparation.IterationPreparation;
if (!string.IsNullOrWhiteSpace(iterationPreparation.ToolResultWaitSummary))
{
WorkflowLogService.LogTransition(
@@ -530,22 +533,6 @@ public partial class AgentLoopService
}
}
// Refresh the session learnings block only for non-Code tabs.
if (!isCodeTab && sessionLearnings is { Count: > 0 })
{
var learningMsg = sessionLearnings.BuildInjectionMessage();
if (learningMsg != null)
{
messages.RemoveAll(m => m.MetaKind == "session_learnings");
messages.Insert(0, new ChatMessage
{
Role = "user",
Content = learningMsg,
MetaKind = "session_learnings",
});
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
@@ -572,14 +559,13 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", $"{tabLabel} 탭에서 사용 가능한 도구가 없습니다.");
return $"⚠ 현재 {tabLabel} 탭에서 사용 가능한 도구가 없어 작업을 진행할 수 없습니다. 탭별 도구 노출 정책을 확인하세요.";
}
var workingSetMessage = codeWorkingSet?.BuildChatMessage();
var llmRequest = AgentLoopLlmRequestPreparationService.Prepare(
var llmRequest = AgentLoopQueryAssemblyService.PrepareRequest(
queryMessages,
codeWorkingSet,
totalToolCalls,
executionPolicy.ForceInitialToolCall,
executionPolicy.InjectPreCallToolReminder,
runState.NoToolCallLoopRetry,
workingSetMessage is null ? null : [workingSetMessage]);
runState.NoToolCallLoopRetry);
var forceFirst = llmRequest.ForceInitialToolCall;
var sendMessages = llmRequest.SendMessages;