From 7c138f8ed922c12905253c08fdb5c1565d387cc2 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 07:18:40 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFtool=5Fresult=20preview=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=EA=B3=BC=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=ED=95=A9=EC=84=B1=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목적: - 긴 세션, 분기, 재시작 이후에도 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) --- README.md | 18 ++++++ docs/DEVELOPMENT.md | 11 ++++ .../Services/ChatSessionStateServiceTests.cs | 40 +++++++++++++- .../Services/SlashCommandCatalogTests.cs | 55 +++++++++++++++++++ .../Agent/AgentMessageInvariantHelper.cs | 45 +++++++++++++++ .../Services/Agent/AgentToolResultBudget.cs | 21 +------ .../Services/ChatSessionStateService.cs | 4 ++ src/AxCopilot/Services/ChatStorageService.cs | 1 + src/AxCopilot/Views/ChatWindow.xaml.cs | 39 ++++++++++++- src/AxCopilot/Views/SlashCommandCatalog.cs | 33 +++++++++-- 10 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs diff --git a/README.md b/README.md index 2e53b20..a14b38d 100644 --- a/README.md +++ b/README.md @@ -1883,3 +1883,21 @@ MIT License - 테스트로 [AgentCommandQueueTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs), [AgentToolResultBudgetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs)를 확장해 `priority batch dequeue`, `predicate matching`, `tool_use_id preview reuse`를 회귀 검증했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_queue_preview\\ -p:IntermediateOutputPath=obj\\verify_queue_preview\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentCommandQueueTests|AgentToolResultBudgetTests" -p:OutputPath=bin\\verify_queue_preview_tests\\ -p:IntermediateOutputPath=obj\\verify_queue_preview_tests\\` 통과 7 + +업데이트: 2026-04-15 07:11 (KST) +- `tool_result` preview 안정화 2차를 반영했습니다. [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)는 `tool_use_id -> QueryPreviewContent` 맵을 만들고, 같은 tool result가 다른 메시지 객체로 다시 구성돼도 preview를 복원하는 helper를 제공합니다. +- [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs)는 분기 대화 생성 시 `QueryPreviewContent`를 함께 복사하고, 저장된 대화를 다시 열 때 누락된 preview를 `tool_use_id` 기준으로 보정합니다. +- [ChatStorageService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatStorageService.cs)는 대화를 저장하기 전에 누락된 tool_result preview를 먼저 채워 넣어 재시작 후에도 축약 상태가 더 안정적으로 유지되도록 정리했습니다. +- 슬래시 합성도 한 단계 다듬었습니다. [SlashCommandCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SlashCommandCatalog.cs)는 같은 우선순위 충돌 시 builtin command보다 skill을 우선하고, 최종 정렬에서도 skill을 앞세워 동적 스킬이 `/` 팔레트에서 덜 가려지게 했습니다. +- 테스트로 [ChatSessionStateServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs)에 branch/save-load preview 복원 케이스를 추가했고, [SlashCommandCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs)를 새로 추가해 skill 우선 dedupe와 정렬을 회귀 검증했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_preview_state\\ -p:IntermediateOutputPath=obj\\verify_preview_state\\` 경고 0 / 오류 0 +- 검증: `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_slash_compose\\ -p:IntermediateOutputPath=obj\\verify_slash_compose\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SlashCommandCatalogTests|ChatSessionStateServiceTests|AgentToolResultBudgetTests" -p:OutputPath=bin\\verify_slash_compose_tests\\ -p:IntermediateOutputPath=obj\\verify_slash_compose_tests\\` 통과 44 + +업데이트: 2026-04-15 07:16 (KST) +- 슬래시 명령 합성 2차를 반영했습니다. [SlashCommandCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SlashCommandCatalog.cs)는 팔레트 렌더링과 동일한 우선순위 규칙을 재사용하는 `ResolvePreferredCommand()`를 추가해, 같은 `/토큰`에 builtin command와 skill이 함께 있을 때도 일관된 해석이 가능하도록 정리했습니다. +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ParseSlashCommandAsync()`는 이제 exact token 후보를 built-in/skill 양쪽에서 모은 뒤 위 합성 규칙으로 우선순위를 결정합니다. 팔레트에는 skill이 앞에 보이는데 실제 실행은 built-in이 먼저 잡히던 불일치를 없앤 변경입니다. +- [SlashCommandCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs)는 exact token 충돌 시 skill 우선 해석을 회귀 검증하도록 확장했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_command_resolution\\ -p:IntermediateOutputPath=obj\\verify_command_resolution\\` 경고 0 / 오류 0 +- 검증: `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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 712f2c3..5446d03 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -959,3 +959,14 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 다음 배치에서는 `tool_result replacement state`를 대화 단위로 더 고정하고, 이후 `명령/스킬 합성 계층`과 `문서 포맷 마감`으로 순차 확장할 예정입니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_queue_preview\\ -p:IntermediateOutputPath=obj\\verify_queue_preview\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentCommandQueueTests|AgentToolResultBudgetTests" -p:OutputPath=bin\\verify_queue_preview_tests\\ -p:IntermediateOutputPath=obj\\verify_queue_preview_tests\\` 통과 7 + +업데이트: 2026-04-15 07:16 (KST) +- `tool_result` preview 안정화 2차를 반영했습니다. [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)는 `tool_use_id -> QueryPreviewContent` 맵을 공용으로 만들고, 같은 tool result가 다른 메시지 객체로 다시 로드되더라도 preview를 복원하는 helper를 제공합니다. +- [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs)는 분기 대화 생성 시 `QueryPreviewContent`를 함께 복사하고, 저장된 대화를 다시 열 때 누락된 preview를 `tool_use_id` 기준으로 보정합니다. [ChatStorageService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatStorageService.cs)도 저장 직전에 preview 보정을 먼저 수행해 재시작 후 축약 상태가 흔들리지 않게 맞췄습니다. +- 슬래시 합성도 실행 경로까지 일원화했습니다. [SlashCommandCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SlashCommandCatalog.cs)는 exact token 충돌을 팔레트와 같은 우선순위로 해석하는 `ResolvePreferredCommand()`를 추가했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ParseSlashCommandAsync()`는 built-in/skill 후보를 함께 모은 뒤 같은 규칙으로 우선 대상을 선택합니다. +- 이 변경으로 `/review`처럼 builtin command와 skill이 같은 토큰을 공유하는 경우에도 “팔레트에는 skill이 앞에 보이는데 실행은 builtin이 먼저 잡히는” 불일치가 줄어들었습니다. +- 테스트는 [ChatSessionStateServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs)에 branch/save-load preview 복원 케이스를 추가했고, [SlashCommandCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs)를 새로 추가해 skill 우선 dedupe와 exact token 우선 해석을 회귀 검증했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_preview_state\\ -p:IntermediateOutputPath=obj\\verify_preview_state\\` 경고 0 / 오류 0 +- 검증: `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\\` 경고 0 / 오류 0 +- 검증: `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 diff --git a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs index ba8c848..a2c339d 100644 --- a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs @@ -642,7 +642,7 @@ public class ChatSessionStateServiceTests ], Messages = [ - new ChatMessage { Role = "user", Content = "u1", Timestamp = DateTime.Now.AddMinutes(-3) }, + new ChatMessage { Role = "user", Content = "u1", Timestamp = DateTime.Now.AddMinutes(-3), QueryPreviewContent = "preview-1" }, new ChatMessage { Role = "assistant", Content = "a1", Timestamp = DateTime.Now.AddMinutes(-2), MetaKind = "meta", MetaRunId = "run-1" }, new ChatMessage { Role = "user", Content = "u2", Timestamp = DateTime.Now.AddMinutes(-1) } ] @@ -655,11 +655,49 @@ public class ChatSessionStateServiceTests branch.WorkFolder.Should().Be(@"E:\workspace"); branch.BranchLabel.Should().Contain("2"); branch.Messages.Should().HaveCount(3); + branch.Messages[0].QueryPreviewContent.Should().Be("preview-1"); branch.Messages[1].MetaRunId.Should().Be("run-1"); branch.Messages[2].MetaKind.Should().Be("branch_context"); branch.AgentRunHistory.Should().ContainSingle(); } + [Fact] + public void LoadOrCreateConversation_RestoresMissingToolResultPreviewFromPersistedMessages() + { + var storage = new ChatStorageService(); + var settings = new SettingsService(); + var conversationId = $"conv-preview-{Guid.NewGuid():N}"; + var conversation = new ChatConversation + { + Id = conversationId, + Tab = "Code", + Title = "preview restore", + Messages = + [ + new ChatMessage + { + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-restore","tool_name":"file_read","content":"long output"}""", + QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-restore","tool_name":"file_read","content":"preview"}""" + }, + new ChatMessage + { + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-restore","tool_name":"file_read","content":"long output"}""" + } + ] + }; + + storage.Save(conversation); + + var session = new ChatSessionStateService(); + session.RememberConversation("Code", conversationId); + var loaded = session.LoadOrCreateConversation("Code", storage, settings); + + loaded.Messages.Should().HaveCount(2); + loaded.Messages[1].QueryPreviewContent.Should().Be(loaded.Messages[0].QueryPreviewContent); + } + [Fact] public void DraftStateHelpers_SelectAndTransitionQueuedItems() { diff --git a/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs b/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs new file mode 100644 index 0000000..589e609 --- /dev/null +++ b/src/AxCopilot.Tests/Services/SlashCommandCatalogTests.cs @@ -0,0 +1,55 @@ +using AxCopilot.Views; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class SlashCommandCatalogTests +{ + [Fact] + public void ComposeMatches_ShouldPreferSkillWhenPriorityTies() + { + var matches = SlashCommandCatalog.ComposeMatches( + [ + (Cmd: "/review", Label: "Builtin Review", IsSkill: false, Priority: 2000), + (Cmd: "/review", Label: "Skill Review", IsSkill: true, Priority: 2000), + ]); + + matches.Should().ContainSingle(); + matches[0].Cmd.Should().Be("/review"); + matches[0].IsSkill.Should().BeTrue(); + matches[0].Label.Should().Be("Skill Review"); + } + + [Fact] + public void ComposeMatches_ShouldSortByPriorityThenSkillBias() + { + var matches = SlashCommandCatalog.ComposeMatches( + [ + (Cmd: "/status", Label: "Status", IsSkill: false, Priority: 2000), + (Cmd: "/alpha", Label: "Alpha Skill", IsSkill: true, Priority: 2000), + (Cmd: "/review", Label: "Review", IsSkill: false, Priority: 2100), + ]); + + matches.Select(x => x.Cmd).Should().Equal("/review", "/alpha", "/status"); + matches[1].IsSkill.Should().BeTrue(); + matches[2].IsSkill.Should().BeFalse(); + } + + [Fact] + public void ResolvePreferredCommand_ShouldPreferSkillForExactTokenWhenPriorityTies() + { + var resolved = SlashCommandCatalog.ResolvePreferredCommand( + "/review", + [ + (Cmd: "/review", Label: "Builtin Review", IsSkill: false, Priority: 2000), + (Cmd: "/review", Label: "Skill Review", IsSkill: true, Priority: 2000), + (Cmd: "/status", Label: "Status", IsSkill: false, Priority: 2000), + ]); + + resolved.HasValue.Should().BeTrue(); + resolved!.Value.Cmd.Should().Be("/review"); + resolved.Value.IsSkill.Should().BeTrue(); + resolved.Value.Label.Should().Be("Skill Review"); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs index 1e9e412..fea8d91 100644 --- a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs +++ b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs @@ -8,6 +8,51 @@ namespace AxCopilot.Services.Agent; /// internal static class AgentMessageInvariantHelper { + public static Dictionary BuildToolResultPreviewMap(IEnumerable? messages) + { + var previews = new Dictionary(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? 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 messages, int startIndex, diff --git a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs index ad8a98e..c9cae5f 100644 --- a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs +++ b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs @@ -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 BuildPreviewByToolResultId(IReadOnlyList? sourceMessages) - { - var previews = new Dictionary(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 diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index c9a253c..276ce3a 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -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(); conversation.DraftQueueItems ??= new List(); + if (AxCopilot.Services.Agent.AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(conversation.Messages)) + changed = true; + var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents); if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents)) { diff --git a/src/AxCopilot/Services/ChatStorageService.cs b/src/AxCopilot/Services/ChatStorageService.cs index e10e1da..8772666 100644 --- a/src/AxCopilot/Services/ChatStorageService.cs +++ b/src/AxCopilot/Services/ChatStorageService.cs @@ -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) { diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 0e19fea..37a44b1 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -3607,7 +3607,44 @@ public partial class ChatWindow : Window { var firstSpace = trimmed.IndexOf(' '); var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim(); - if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry)) + var isDev = _activeTab is "Cowork" or "Code"; + var exactEntries = new List<(string Cmd, string Label, bool IsSkill, int Priority)>(); + + if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry) + && (entry.Tab == "all" || (isDev && entry.Tab == "dev"))) + { + exactEntries.Add(( + Cmd: commandToken, + Label: entry.Label, + IsSkill: false, + Priority: SlashCommandCatalog.GetBuiltInCommandPriority(commandToken))); + } + + if (_settings.Settings.Llm.EnableSkillSystem) + { + EnsureSkillSystemLoadedForCurrentWorkspace(); + exactEntries.AddRange( + SkillService.MatchSlashCommand(commandToken) + .Where(s => s.IsVisibleInTab(_activeTab)) + .Where(s => string.Equals("/" + s.Name, commandToken, StringComparison.OrdinalIgnoreCase)) + .Select(s => ( + Cmd: "/" + s.Name, + Label: BuildSlashSkillLabel(s), + IsSkill: true, + Priority: SkillService.GetSkillSourcePriority(s.SourceScope)))); + } + + var preferredCommand = SlashCommandCatalog.ResolvePreferredCommand(commandToken, exactEntries); + if (preferredCommand.HasValue && preferredCommand.Value.IsSkill) + { + var compiled = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct); + if (compiled != null) + return (compiled.SystemPrompt, compiled.DisplayText); + } + + if (preferredCommand.HasValue + && !preferredCommand.Value.IsSkill + && SlashCommandCatalog.TryGetEntry(commandToken, out entry)) { // __HELP__는 특수 처리 (ParseSlashCommand에서는 무시) if (entry.SystemPrompt == "__HELP__") return (null, input); diff --git a/src/AxCopilot/Views/SlashCommandCatalog.cs b/src/AxCopilot/Views/SlashCommandCatalog.cs index 922d4bf..ed4bfbc 100644 --- a/src/AxCopilot/Views/SlashCommandCatalog.cs +++ b/src/AxCopilot/Views/SlashCommandCatalog.cs @@ -128,19 +128,42 @@ internal static class SlashCommandCatalog if (entries == null) return []; + return OrderEntries(entries) + .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill, entry.Priority)) + .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill)) + .ToList(); + } + + internal static (string Cmd, string Label, bool IsSkill)? ResolvePreferredCommand( + string commandToken, + IEnumerable<(string Cmd, string Label, bool IsSkill, int Priority)> entries) + { + if (string.IsNullOrWhiteSpace(commandToken) || entries == null) + return null; + + var resolved = OrderEntries(entries.Where(entry => + string.Equals(entry.Cmd, commandToken, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(); + + return string.IsNullOrWhiteSpace(resolved.Cmd) + ? null + : (resolved.Cmd, resolved.Label, resolved.IsSkill); + } + + private static IEnumerable<(string Cmd, string Label, bool IsSkill, int Priority)> OrderEntries( + IEnumerable<(string Cmd, string Label, bool IsSkill, int Priority)> entries) + { return entries .Where(entry => !string.IsNullOrWhiteSpace(entry.Cmd)) .GroupBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) .Select(group => group .OrderByDescending(entry => entry.Priority) - .ThenBy(entry => entry.IsSkill ? 1 : 0) + .ThenByDescending(entry => entry.IsSkill) .ThenBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) .First()) - .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill, entry.Priority)) .OrderByDescending(entry => entry.Priority) - .ThenBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) - .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill)) - .ToList(); + .ThenByDescending(entry => entry.IsSkill) + .ThenBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase); } internal static bool TryGetEntry(string commandToken, out (string Label, string SystemPrompt, string Tab) entry)