tool_result preview 복원과 슬래시 명령 합성 일원화

목적:
- 긴 세션, 분기, 재시작 이후에도 tool_result preview 축약 상태를 더 안정적으로 유지합니다.
- 슬래시 팔레트와 실제 /토큰 실행 해석이 어긋나지 않도록 built-in command와 skill 우선순위를 같은 규칙으로 맞춥니다.

핵심 수정:
- AgentMessageInvariantHelper에 tool_use_id 기준 preview 맵/복원 helper를 추가했습니다.
- ChatSessionStateService는 분기 대화 생성 시 QueryPreviewContent를 함께 복사하고, 저장된 대화를 다시 열 때 누락된 preview를 복원합니다.
- ChatStorageService는 저장 직전에 누락된 tool_result preview를 먼저 채워 재시작 후 축약 상태가 흔들리지 않게 정리했습니다.
- SlashCommandCatalog에 exact token 충돌 해석용 ResolvePreferredCommand를 추가하고, ChatWindow.ParseSlashCommandAsync가 built-in/skill 후보를 함께 모아 같은 우선순위 규칙으로 실행 대상을 선택하도록 맞췄습니다.
- SlashCommandCatalogTests를 새로 추가하고 ChatSessionStateServiceTests를 확장해 preview 복원과 skill 우선 해석을 회귀 검증했습니다.
- README.md, docs/DEVELOPMENT.md를 2026-04-15 07:16 (KST) 기준으로 갱신했습니다.

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_preview_state\\ -p:IntermediateOutputPath=obj\\verify_preview_state\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolResultBudgetTests|ChatSessionStateServiceTests" -p:OutputPath=bin\\verify_preview_state_tests\\ -p:IntermediateOutputPath=obj\\verify_preview_state_tests\\ (통과 38)
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_command_resolution\\ -p:IntermediateOutputPath=obj\\verify_command_resolution\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SlashCommandCatalogTests|ChatSessionStateServiceTests|AgentToolResultBudgetTests|AgentCommandQueueTests" -p:OutputPath=bin\\verify_command_resolution_tests\\ -p:IntermediateOutputPath=obj\\verify_command_resolution_tests\\ (통과 50)
This commit is contained in:
2026-04-15 07:18:40 +09:00
parent 07fd2267cb
commit 7c138f8ed9
10 changed files with 240 additions and 27 deletions

View File

@@ -8,6 +8,51 @@ namespace AxCopilot.Services.Agent;
/// </summary>
internal static class AgentMessageInvariantHelper
{
public static Dictionary<string, string> BuildToolResultPreviewMap(IEnumerable<ChatMessage>? messages)
{
var previews = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (messages == null)
return previews;
foreach (var message in messages)
{
if (string.IsNullOrWhiteSpace(message.QueryPreviewContent))
continue;
if (!TryGetToolResultId(message, out var toolResultId))
continue;
previews[toolResultId] = message.QueryPreviewContent!;
}
return previews;
}
public static bool PopulateMissingToolResultPreviews(IList<ChatMessage>? messages)
{
if (messages == null || messages.Count == 0)
return false;
var previews = BuildToolResultPreviewMap(messages);
if (previews.Count == 0)
return false;
var changed = false;
foreach (var message in messages)
{
if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent))
continue;
if (!TryGetToolResultId(message, out var toolResultId))
continue;
if (!previews.TryGetValue(toolResultId, out var preview))
continue;
message.QueryPreviewContent = preview;
changed = true;
}
return changed;
}
public static int AdjustStartIndexForToolPairs(
IReadOnlyList<ChatMessage> messages,
int startIndex,

View File

@@ -30,7 +30,7 @@ public static class AgentToolResultBudget
var sourceById = sourceMessages?
.Where(message => !string.IsNullOrWhiteSpace(message.MsgId))
.ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase);
var sourcePreviewByToolResultId = BuildPreviewByToolResultId(sourceMessages);
var sourcePreviewByToolResultId = AgentMessageInvariantHelper.BuildToolResultPreviewMap(sourceMessages);
var nonSystemIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
@@ -100,25 +100,6 @@ public static class AgentToolResultBudget
return result;
}
private static Dictionary<string, string> BuildPreviewByToolResultId(IReadOnlyList<ChatMessage>? sourceMessages)
{
var previews = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (sourceMessages == null)
return previews;
foreach (var message in sourceMessages)
{
if (string.IsNullOrWhiteSpace(message.QueryPreviewContent))
continue;
if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out var toolResultId))
continue;
previews[toolResultId] = message.QueryPreviewContent!;
}
return previews;
}
public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit)
{
try

View File

@@ -237,6 +237,7 @@ public sealed class ChatSessionStateService
MetaRunId = m.MetaRunId,
Feedback = m.Feedback,
AttachedFiles = m.AttachedFiles?.ToList(),
QueryPreviewContent = m.QueryPreviewContent,
Images = m.Images?.Select(img => new ImageAttachment
{
FileName = img.FileName,
@@ -680,6 +681,9 @@ public sealed class ChatSessionStateService
conversation.AgentRunHistory ??= new List<ChatAgentRunRecord>();
conversation.DraftQueueItems ??= new List<DraftQueueItem>();
if (AxCopilot.Services.Agent.AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(conversation.Messages))
changed = true;
var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents);
if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents))
{

View File

@@ -34,6 +34,7 @@ public class ChatStorageService : IChatStorageService
public void Save(ChatConversation conversation)
{
conversation.UpdatedAt = DateTime.Now;
AxCopilot.Services.Agent.AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(conversation.Messages);
// 검색용 미리보기 자동 갱신 (첫 사용자 메시지 100자)
if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0)
{